Coverage for astrocyte/mcp.py: 52%

349 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""Astrocyte MCP server — exposes memory tools via Model Context Protocol. 

2 

3Usage: 

4 astrocyte-mcp --config astrocyte.yaml 

5 astrocyte-mcp --config astrocyte.yaml --transport sse --port 8080 

6 

7See docs/_design/mcp-server.md for the full specification. 

8 

9Note: Access control in MCP deployments typically uses one principal from 

10``mcp.principal`` (defaults to ``agent:mcp``), or pass 

11``astrocyte_context=...`` to :func:`create_mcp_server` when your host maps 

12callers to identity. Per-caller identity still requires transport-level auth 

13at the MCP host. 

14""" 

15 

16from __future__ import annotations 

17 

18import argparse 

19import json 

20import logging 

21import os 

22import sys 

23from datetime import datetime 

24from pathlib import Path 

25from typing import Any 

26 

27from fastmcp import FastMCP 

28 

29from astrocyte._astrocyte import Astrocyte 

30from astrocyte._mcp_identity import JwtIdentityMiddleware, build_jwt_middleware 

31from astrocyte.config import AstrocyteConfig, load_config 

32from astrocyte.types import AstrocyteContext 

33 

34logger = logging.getLogger("astrocyte.mcp") 

35 

36# --------------------------------------------------------------------------- 

37# Server factory 

38# --------------------------------------------------------------------------- 

39 

40 

41def server_instructions(mcp_cfg) -> str: 

42 """What the agent is told this server offers — only tools it registers. 

43 

44 It used to advertise memory_reflect and memory_forget unconditionally, so 

45 with the defaults (forget hidden) an agent asked to forget something went 

46 looking for a tool that does not exist. 

47 """ 

48 uses = ["memory_retain to store information", "memory_recall to search memories"] 

49 if mcp_cfg.expose_reflect: 

50 uses.append("memory_reflect to synthesize answers") 

51 if mcp_cfg.expose_forget: 

52 uses.append("memory_forget to remove memories") 

53 text = "Astrocyte memory server. Use " + ", ".join(uses[:-1]) + ", and " + uses[-1] + "." 

54 if not mcp_cfg.expose_forget: 

55 text += ( 

56 " This server cannot delete memories. If the user asks to remove one, find it with " 

57 "memory_recall and give them its memory_id; on a local install they remove it with " 

58 "`astrocyte memory forget <memory_id> --bank <bank_id>`, which erases it from disk." 

59 ) 

60 return text 

61 

62 

63def create_mcp_server( 

64 brain: Astrocyte, 

65 config: AstrocyteConfig, 

66 *, 

67 astrocyte_context: AstrocyteContext | None = None, 

68 jwt_middleware: JwtIdentityMiddleware | None = None, 

69 default_bank_fallback: str | None = None, 

70) -> FastMCP: 

71 """Create a FastMCP server wired to an Astrocyte instance. 

72 

73 Identity resolution order (per call): 

74 

75 1. If ``astrocyte_context`` was passed at server-creation time, it is 

76 used as-is (legacy embedding pattern). This takes precedence over 

77 all header-based resolution so tests and embedded deployments that 

78 pre-bind identity are unaffected. 

79 2. Else if ``jwt_middleware`` is set OR 

80 ``config.identity.jwt_middleware.enabled`` is true, per-call 

81 identity is resolved from the inbound request's Authorization 

82 header (identity spec §3 Gap 1 wiring). See 

83 ``docs/_plugins/jwt-identity-middleware.md`` for operator setup. 

84 3. Else the server falls back to the static ``mcp.principal`` label 

85 (default ``agent:mcp``) — the pre-middleware behavior. 

86 

87 Args: 

88 jwt_middleware: Optional pre-built middleware. When None but the 

89 config enables JWT middleware, the server constructs one via 

90 :func:`build_jwt_middleware`. Pass explicitly to inject a 

91 test decoder or bypass PyJWT. 

92 """ 

93 

94 mcp_cfg = config.mcp 

95 mcp = FastMCP(name="astrocyte-memory", instructions=server_instructions(mcp_cfg)) 

96 

97 # Static context — still built for the pre-middleware path (legacy 

98 # deployments that don't enable JWT middleware). When middleware is 

99 # enabled, this is only used if header resolution is somehow bypassed. 

100 static_ctx = astrocyte_context or AstrocyteContext( 

101 principal=mcp_cfg.principal or "agent:mcp", 

102 ) 

103 

104 # Build or accept JWT middleware. Explicit > config > none. 

105 if jwt_middleware is None and config.identity.jwt_middleware.enabled: 

106 jwt_middleware = build_jwt_middleware(config.identity.jwt_middleware) 

107 

108 def _resolve_context() -> AstrocyteContext: 

109 """Resolve the AstrocyteContext for the current request. 

110 

111 Called on every tool invocation so the middleware sees each call's 

112 headers. Static ``astrocyte_context`` (when set at server creation) 

113 always wins — that preserves backward compatibility for embedded 

114 hosts that pre-bind identity. 

115 """ 

116 # An explicit pre-bound context always wins (embedded / test use). 

117 if astrocyte_context is not None: 

118 return astrocyte_context 

119 # Middleware path — pull headers, resolve identity, build ctx. 

120 if jwt_middleware is not None: 

121 try: 

122 from fastmcp.server.dependencies import get_http_headers 

123 

124 headers = get_http_headers(include={"authorization"}) 

125 except Exception: 

126 # stdio transport or pre-request context — no headers. 

127 # Middleware policy decides how to treat the missing 

128 # header (fail-closed vs anonymous). 

129 headers = {} 

130 return jwt_middleware.resolve(headers) 

131 # No middleware, no pre-bound ctx — fall through to static. 

132 return static_ctx 

133 

134 # Default bank. ``default_bank_fallback`` (the project bank, for a local 

135 # stdio server) applies only when the config names none: without it a 

136 # memory_retain that omits bank_id fails, and the agent's memories would 

137 # never meet the ones its hooks capture. 

138 default_bank = mcp_cfg.default_bank_id or default_bank_fallback 

139 

140 _MAX_TAGS = 20 

141 _MAX_TAG_LENGTH = 255 

142 

143 def _resolve_bank(bank_id: str | None) -> str: 

144 if bank_id: 

145 return bank_id 

146 if default_bank: 

147 return default_bank 

148 raise ValueError("bank_id is required (no default_bank_id configured)") 

149 

150 def _validate_tags(tags: list[str] | None) -> list[str] | None: 

151 if not tags: 

152 return tags 

153 if len(tags) > _MAX_TAGS: 

154 raise ValueError(f"Too many tags ({len(tags)}), max {_MAX_TAGS}") 

155 for tag in tags: 

156 if not isinstance(tag, str) or len(tag) > _MAX_TAG_LENGTH: 

157 raise ValueError(f"Invalid tag (must be string, max {_MAX_TAG_LENGTH} chars)") 

158 return tags 

159 

160 # ── memory_retain ────────────────────────────────────────────── 

161 

162 @mcp.tool() 

163 async def memory_retain( 

164 content: str, 

165 bank_id: str | None = None, 

166 tags: list[str] | None = None, 

167 metadata: dict[str, str] | None = None, 

168 occurred_at: str | None = None, 

169 ) -> str: 

170 """Store content into memory. 

171 

172 Args: 

173 content: The text to memorize. 

174 bank_id: Memory bank to store in. Uses default if omitted. 

175 tags: Optional tags for filtering. 

176 metadata: Optional key-value metadata. 

177 occurred_at: ISO 8601 timestamp of when the event happened. 

178 """ 

179 try: 

180 bid = _resolve_bank(bank_id) 

181 tags = _validate_tags(tags) 

182 kwargs: dict[str, Any] = {} 

183 if occurred_at: 

184 try: 

185 kwargs["occurred_at"] = datetime.fromisoformat(occurred_at) 

186 except ValueError: 

187 return json.dumps({"stored": False, "error": "Invalid occurred_at format; expected ISO 8601"}) 

188 result = await brain.retain( 

189 content, 

190 bank_id=bid, 

191 tags=tags, 

192 metadata=metadata, 

193 context=_resolve_context(), 

194 **kwargs, 

195 ) 

196 if result.stored: 

197 return json.dumps({"stored": True, "memory_id": result.memory_id}) 

198 return json.dumps({"stored": False, "error": "Failed to store memory"}) 

199 except Exception as exc: 

200 logger.exception("memory_retain failed") 

201 return json.dumps({"stored": False, "error": type(exc).__name__}) 

202 

203 # ── memory_recall ────────────────────────────────────────────── 

204 

205 @mcp.tool() 

206 async def memory_recall( 

207 query: str, 

208 bank_id: str | None = None, 

209 banks: list[str] | None = None, 

210 strategy: str | None = None, 

211 max_results: int = 10, 

212 tags: list[str] | None = None, 

213 session_id: str | None = None, 

214 ) -> str: 

215 """Retrieve relevant memories for a query — RAW retrieval, no synthesis. 

216 

217 Returns a ranked list of memory ``hits``. The caller is expected to 

218 compose its own answer from these hits (this is the "retrieval 

219 primitive" — you, the calling agent, are the answerer). 

220 

221 **Use `memory_recall` when**: you want to read the underlying 

222 memories and synthesize them yourself, OR you need to show citations 

223 / quotes verbatim, OR you need fine control over which memories 

224 end up in your final response. 

225 

226 **Use `memory_reflect` instead when**: you want a finished answer 

227 you can pass straight through to the user. Reflect does its own 

228 synthesis via an agentic loop (multi-step retrieval + reasoning) 

229 and returns one composed answer with cited sources. Cheaper for 

230 you than calling `memory_recall` + a follow-up LLM round-trip. 

231 

232 Args: 

233 query: Natural language query. 

234 bank_id: Single memory bank to search. Uses default if omitted. 

235 banks: Multiple banks to search (overrides bank_id). 

236 strategy: Multi-bank strategy: "cascade", "parallel", or "first_match". 

237 max_results: Maximum number of results. 

238 tags: Optional tag filter. 

239 session_id: M31 Fix 2 — opaque session identifier. When the 

240 calling application knows which conversation session the 

241 query targets, pass it to scope retrieval to that 

242 session's facts/sections. ``None`` (default) preserves 

243 cross-session retrieval. 

244 """ 

245 try: 

246 max_results = max(1, min(max_results, mcp_cfg.max_results_limit)) 

247 tags = _validate_tags(tags) 

248 

249 if banks: 

250 result = await brain.recall( 

251 query, 

252 banks=banks, 

253 strategy=strategy, 

254 max_results=max_results, 

255 tags=tags, 

256 context=_resolve_context(), 

257 session_id=session_id, # M31 Fix 2 

258 ) 

259 else: 

260 bid = _resolve_bank(bank_id) 

261 result = await brain.recall( 

262 query, 

263 bank_id=bid, 

264 max_results=max_results, 

265 tags=tags, 

266 context=_resolve_context(), 

267 session_id=session_id, # M31 Fix 2 

268 ) 

269 

270 hits = [ 

271 { 

272 "text": h.text, 

273 "score": round(h.score, 4), 

274 "fact_type": h.fact_type, 

275 "bank_id": h.bank_id, 

276 "memory_id": h.memory_id, 

277 } 

278 for h in result.hits 

279 ] 

280 return json.dumps( 

281 { 

282 "hits": hits, 

283 "total_available": result.total_available, 

284 "truncated": result.truncated, 

285 } 

286 ) 

287 except Exception as exc: 

288 logger.exception("memory_recall failed") 

289 return json.dumps({"hits": [], "total_available": 0, "error": type(exc).__name__}) 

290 

291 # ── memory_reflect ───────────────────────────────────────────── 

292 

293 if mcp_cfg.expose_reflect: 

294 

295 @mcp.tool() 

296 async def memory_reflect( 

297 query: str, 

298 bank_id: str | None = None, 

299 banks: list[str] | None = None, 

300 strategy: str | None = None, 

301 max_tokens: int | None = None, 

302 include_sources: bool = True, 

303 ) -> str: 

304 """Get a finished, cited answer to a question from memory. 

305 

306 Returns ``{"answer": <synthesized text>, "sources": [...]}``. 

307 The ``answer`` IS the response — Astrocyte's internal agentic 

308 loop ran multi-step retrieval + reasoning and composed it. 

309 

310 **How to use the `answer` field:** 

311 - Quote it verbatim, OR paraphrase it into your own voice, OR 

312 return it directly to the user. 

313 - **Do NOT pass it back through another LLM for re-synthesis.** 

314 That double-synthesis pattern loses citation fidelity, doubles 

315 cost, and produces strictly worse answers than either pure 

316 path. If you want raw memories to compose yourself, use 

317 `memory_recall` instead. 

318 - `sources` carries the cited memory chunks for traceability / 

319 UI rendering — show them as citations next to the answer, 

320 don't re-feed them into a synth prompt. 

321 

322 **Use `memory_reflect` when**: the user asked a question and you 

323 want a complete, citation-grounded answer back. Especially good 

324 for multi-hop questions ("how did X relate to Y?"), synthesis 

325 questions ("summarize what we discussed about Z"), and any 

326 question where the answer requires reasoning across several 

327 memories. 

328 

329 **Use `memory_recall` instead when**: you want raw memories 

330 (e.g. to show as a search result list) or you want to do your 

331 own synthesis with strong opinions on prompt / format. 

332 

333 Args: 

334 query: The question to answer from memory. 

335 bank_id: Single memory bank. Uses default if omitted. 

336 banks: Multiple banks (overrides bank_id). 

337 strategy: Multi-bank strategy: "cascade", "parallel", or "first_match". 

338 max_tokens: Maximum tokens for the synthesized answer. 

339 include_sources: Whether to include source memories 

340 (default True — needed for showing citations). 

341 """ 

342 try: 

343 if banks: 

344 result = await brain.reflect( 

345 query, 

346 banks=banks, 

347 strategy=strategy, 

348 max_tokens=max_tokens, 

349 context=_resolve_context(), 

350 include_sources=include_sources, 

351 ) 

352 else: 

353 bid = _resolve_bank(bank_id) 

354 result = await brain.reflect( 

355 query, 

356 bank_id=bid, 

357 max_tokens=max_tokens, 

358 context=_resolve_context(), 

359 include_sources=include_sources, 

360 ) 

361 

362 out: dict[str, Any] = {"answer": result.answer} 

363 if include_sources and result.sources: 

364 out["sources"] = [ 

365 {"text": s.text, "score": round(s.score, 4), "bank_id": s.bank_id} for s in result.sources 

366 ] 

367 return json.dumps(out) 

368 except Exception as exc: 

369 logger.exception("memory_reflect failed") 

370 return json.dumps({"answer": "", "error": type(exc).__name__}) 

371 

372 # ── memory_forget ────────────────────────────────────────────── 

373 

374 if mcp_cfg.expose_forget: 

375 

376 @mcp.tool() 

377 async def memory_forget( 

378 bank_id: str | None = None, 

379 memory_ids: list[str] | None = None, 

380 tags: list[str] | None = None, 

381 ) -> str: 

382 """Remove memories from a bank. 

383 

384 Args: 

385 bank_id: Memory bank. Uses default if omitted. 

386 memory_ids: Specific memory IDs to delete. 

387 tags: Delete memories matching these tags. 

388 """ 

389 try: 

390 bid = _resolve_bank(bank_id) 

391 tags = _validate_tags(tags) 

392 result = await brain.forget( 

393 bid, 

394 memory_ids=memory_ids, 

395 tags=tags, 

396 context=_resolve_context(), 

397 ) 

398 return json.dumps( 

399 { 

400 "deleted_count": result.deleted_count, 

401 "archived_count": result.archived_count, 

402 } 

403 ) 

404 except Exception as exc: 

405 logger.exception("memory_forget failed") 

406 return json.dumps({"deleted_count": 0, "error": type(exc).__name__}) 

407 

408 # ── memory_history ───────────────────────────────────────────── 

409 

410 @mcp.tool() 

411 async def memory_history( 

412 query: str, 

413 bank_id: str | None = None, 

414 as_of: str | None = None, 

415 max_results: int = 10, 

416 tags: list[str] | None = None, 

417 ) -> str: 

418 """Reconstruct what the agent knew at a past point in time (M9 time travel). 

419 

420 Args: 

421 query: Recall query to run against the historical snapshot. 

422 bank_id: Bank to query. Uses default if omitted. 

423 as_of: ISO 8601 UTC datetime — memories retained after this moment 

424 are hidden (e.g. "2025-01-01T00:00:00Z"). 

425 max_results: Maximum hits to return (default 10). 

426 tags: Optional tag filter applied on top of the time filter. 

427 """ 

428 try: 

429 bid = _resolve_bank(bank_id) 

430 tags = _validate_tags(tags) 

431 if not as_of: 

432 return json.dumps({"hits": [], "error": "as_of (ISO 8601) is required"}) 

433 try: 

434 as_of_dt = datetime.fromisoformat(as_of) 

435 except ValueError: 

436 return json.dumps({"hits": [], "error": "Invalid as_of format; expected ISO 8601"}) 

437 result = await brain.history( 

438 query, 

439 bid, 

440 as_of_dt, 

441 max_results=max(1, min(max_results, mcp_cfg.max_results_limit)), 

442 tags=tags, 

443 context=_resolve_context(), 

444 ) 

445 hits = [ 

446 { 

447 "text": h.text, 

448 "score": round(h.score, 4), 

449 "fact_type": h.fact_type, 

450 "bank_id": h.bank_id, 

451 "memory_id": h.memory_id, 

452 } 

453 for h in result.hits 

454 ] 

455 return json.dumps( 

456 { 

457 "hits": hits, 

458 "total_available": result.total_available, 

459 "truncated": result.truncated, 

460 "as_of": result.as_of.isoformat(), 

461 "bank_id": result.bank_id, 

462 } 

463 ) 

464 except Exception as exc: 

465 logger.exception("memory_history failed") 

466 return json.dumps({"hits": [], "error": type(exc).__name__}) 

467 

468 # ── memory_audit ──────────────────────────────────────────────── 

469 

470 @mcp.tool() 

471 async def memory_audit( 

472 scope: str, 

473 bank_id: str | None = None, 

474 max_memories: int = 50, 

475 tags: list[str] | None = None, 

476 ) -> str: 

477 """Identify knowledge gaps for a topic in a memory bank (M10). 

478 

479 Use this to discover what the agent does not know about a subject 

480 before relying on recall for important decisions. 

481 

482 Args: 

483 scope: Natural-language topic to audit (e.g. "Alice's employment history"). 

484 bank_id: Bank to audit. Uses default if omitted. 

485 max_memories: Memories to retrieve and scan (default 50). 

486 tags: Optional tag filter to narrow the retrieved memories. 

487 """ 

488 try: 

489 bid = _resolve_bank(bank_id) 

490 tags = _validate_tags(tags) 

491 result = await brain.audit( 

492 scope, 

493 bid, 

494 max_memories=max(1, min(max_memories, 200)), 

495 tags=tags, 

496 ) 

497 gaps = [{"topic": g.topic, "severity": g.severity, "reason": g.reason} for g in result.gaps] 

498 return json.dumps( 

499 { 

500 "scope": result.scope, 

501 "bank_id": result.bank_id, 

502 "coverage_score": round(result.coverage_score, 3), 

503 "memories_scanned": result.memories_scanned, 

504 "gaps": gaps, 

505 } 

506 ) 

507 except Exception as exc: 

508 logger.exception("memory_audit failed") 

509 return json.dumps({"gaps": [], "coverage_score": 0.0, "error": type(exc).__name__}) 

510 

511 # ── memory_compile ───────────────────────────────────────────── 

512 

513 @mcp.tool() 

514 async def memory_compile( 

515 bank_id: str | None = None, 

516 scope: str | None = None, 

517 ) -> str: 

518 """Compile raw memories into structured wiki pages (M8). 

519 

520 Synthesises a wiki page for each detected topic scope using the LLM. 

521 Call this periodically to distil accumulated memories into a curated 

522 knowledge base that recall can surface ahead of raw fragments. 

523 

524 Args: 

525 bank_id: Bank to compile. Uses default if omitted. 

526 scope: Compile only memories tagged with this scope string. 

527 Omit to trigger full scope discovery (tag grouping + 

528 embedding cluster labelling across the whole bank). 

529 """ 

530 try: 

531 bid = _resolve_bank(bank_id) 

532 result = await brain.compile(bid, scope=scope) 

533 out: dict[str, Any] = { 

534 "bank_id": result.bank_id, 

535 "pages_created": result.pages_created, 

536 "pages_updated": result.pages_updated, 

537 "scopes_compiled": result.scopes_compiled, 

538 "noise_memories": result.noise_memories, 

539 "tokens_used": result.tokens_used, 

540 "elapsed_ms": result.elapsed_ms, 

541 } 

542 if result.error: 

543 out["error"] = result.error 

544 return json.dumps(out) 

545 except Exception as exc: 

546 logger.exception("memory_compile failed") 

547 return json.dumps({"pages_created": 0, "pages_updated": 0, "error": type(exc).__name__}) 

548 

549 # ── memory_graph_search ──────────────────────────────────────── 

550 

551 @mcp.tool() 

552 async def memory_graph_search( 

553 query: str, 

554 bank_id: str | None = None, 

555 limit: int = 10, 

556 ) -> str: 

557 """Search the knowledge graph for entities matching a name. 

558 

559 Returns matching entities with their IDs. Use the IDs with 

560 memory_graph_neighbors to traverse connected memories. 

561 

562 Args: 

563 query: Entity name or partial name to search for. 

564 bank_id: Bank whose graph to search. Uses default if omitted. 

565 limit: Maximum number of entities to return. 

566 """ 

567 try: 

568 bid = _resolve_bank(bank_id) 

569 entities = await brain.graph_search(query, bid, limit=limit) 

570 return json.dumps( 

571 { 

572 "entities": [ 

573 { 

574 "id": e.id, 

575 "name": e.name, 

576 "entity_type": e.entity_type, 

577 "aliases": e.aliases or [], 

578 } 

579 for e in entities 

580 ] 

581 } 

582 ) 

583 except Exception as exc: 

584 logger.exception("memory_graph_search failed") 

585 return json.dumps({"entities": [], "error": type(exc).__name__}) 

586 

587 # ── memory_graph_neighbors ───────────────────────────────────── 

588 

589 @mcp.tool() 

590 async def memory_graph_neighbors( 

591 entity_ids: list[str], 

592 bank_id: str | None = None, 

593 max_depth: int = 2, 

594 limit: int = 20, 

595 ) -> str: 

596 """Traverse the knowledge graph from seed entity IDs. 

597 

598 Walks the entity graph up to max_depth hops from each seed entity 

599 and returns memories attached to discovered entities, scored by 

600 proximity. Use memory_graph_search first to resolve entity IDs. 

601 

602 Args: 

603 entity_ids: Seed entity IDs to start traversal from. 

604 bank_id: Bank whose graph to traverse. Uses default if omitted. 

605 max_depth: Maximum traversal depth (default 2). 

606 limit: Maximum number of memory hits to return. 

607 """ 

608 try: 

609 bid = _resolve_bank(bank_id) 

610 hits = await brain.graph_neighbors(entity_ids, bid, max_depth=max_depth, limit=limit) 

611 return json.dumps( 

612 { 

613 "hits": [ 

614 { 

615 "memory_id": h.memory_id, 

616 "text": h.text, 

617 "connected_entities": h.connected_entities, 

618 "depth": h.depth, 

619 "score": round(h.score, 4), 

620 } 

621 for h in hits 

622 ] 

623 } 

624 ) 

625 except Exception as exc: 

626 logger.exception("memory_graph_neighbors failed") 

627 return json.dumps({"hits": [], "error": type(exc).__name__}) 

628 

629 # ── admin tools (expose_admin=true) ──────────────────────────── 

630 

631 if mcp_cfg.expose_admin: 

632 

633 @mcp.tool() 

634 async def memory_lifecycle( 

635 bank_id: str | None = None, 

636 ) -> str: 

637 """Run TTL lifecycle sweep on a bank — archives and deletes expired memories. 

638 

639 Args: 

640 bank_id: Bank to sweep. Uses default if omitted. 

641 """ 

642 try: 

643 bid = _resolve_bank(bank_id) 

644 result = await brain.run_lifecycle(bid) 

645 return json.dumps( 

646 { 

647 "bank_id": bid, 

648 "archived_count": result.archived_count, 

649 "deleted_count": result.deleted_count, 

650 "skipped_count": result.skipped_count, 

651 } 

652 ) 

653 except Exception as exc: 

654 logger.exception("memory_lifecycle failed") 

655 return json.dumps({"archived_count": 0, "deleted_count": 0, "error": type(exc).__name__}) 

656 

657 @mcp.tool() 

658 async def memory_bank_health( 

659 bank_id: str | None = None, 

660 ) -> str: 

661 """Get health score and issues for a memory bank. 

662 

663 Args: 

664 bank_id: Bank to assess. Uses default if omitted. Pass "__all__" 

665 to get health for every bank that has recorded operations. 

666 """ 

667 try: 

668 if bank_id == "__all__": 

669 results = await brain.all_bank_health() 

670 return json.dumps( 

671 { 

672 "banks": [ 

673 { 

674 "bank_id": r.bank_id, 

675 "score": round(r.score, 3), 

676 "status": r.status, 

677 "issues": [ 

678 {"severity": i.severity, "code": i.code, "message": i.message} for i in r.issues 

679 ], 

680 } 

681 for r in results 

682 ] 

683 } 

684 ) 

685 bid = _resolve_bank(bank_id) 

686 result = await brain.bank_health(bid) 

687 return json.dumps( 

688 { 

689 "bank_id": result.bank_id, 

690 "score": round(result.score, 3), 

691 "status": result.status, 

692 "issues": [ 

693 {"severity": i.severity, "code": i.code, "message": i.message} for i in result.issues 

694 ], 

695 "metrics": result.metrics, 

696 } 

697 ) 

698 except Exception as exc: 

699 logger.exception("memory_bank_health failed") 

700 return json.dumps({"score": 0.0, "error": type(exc).__name__}) 

701 

702 @mcp.tool() 

703 async def memory_hold_set( 

704 hold_id: str, 

705 reason: str, 

706 bank_id: str | None = None, 

707 set_by: str = "agent:mcp", 

708 ) -> str: 

709 """Place a bank under legal hold — blocks memory_forget until released. 

710 

711 Args: 

712 hold_id: Unique identifier for this hold (used to release it later). 

713 reason: Human-readable reason for the hold. 

714 bank_id: Bank to place on hold. Uses default if omitted. 

715 set_by: Actor label, default "agent:mcp". 

716 """ 

717 try: 

718 bid = _resolve_bank(bank_id) 

719 hold = await brain.place_legal_hold(bid, hold_id, reason, set_by=set_by) 

720 return json.dumps( 

721 { 

722 "hold_id": hold.hold_id, 

723 "bank_id": hold.bank_id, 

724 "reason": hold.reason, 

725 "set_by": hold.set_by, 

726 "set_at": hold.set_at.isoformat(), 

727 } 

728 ) 

729 except Exception as exc: 

730 logger.exception("memory_hold_set failed") 

731 return json.dumps({"error": type(exc).__name__}) 

732 

733 @mcp.tool() 

734 async def memory_hold_release( 

735 hold_id: str, 

736 bank_id: str | None = None, 

737 ) -> str: 

738 """Release a legal hold from a bank. 

739 

740 Args: 

741 hold_id: The hold ID to release. 

742 bank_id: Bank to release the hold from. Uses default if omitted. 

743 """ 

744 try: 

745 bid = _resolve_bank(bank_id) 

746 released = await brain.lift_legal_hold(bid, hold_id) 

747 return json.dumps({"bank_id": bid, "hold_id": hold_id, "released": released}) 

748 except Exception as exc: 

749 logger.exception("memory_hold_release failed") 

750 return json.dumps({"released": False, "error": type(exc).__name__}) 

751 

752 # ── memory_banks ─────────────────────────────────────────────── 

753 

754 @mcp.tool() 

755 async def memory_banks() -> str: 

756 """List available memory banks.""" 

757 # For now, return banks from config. In future, query provider. 

758 bank_ids: list[str] = [] 

759 if config.banks: 

760 bank_ids = list(config.banks.keys()) 

761 if default_bank and default_bank not in bank_ids: 

762 bank_ids.insert(0, default_bank) 

763 return json.dumps({"banks": bank_ids, "default": default_bank}) 

764 

765 # ── M21 — Mental-model CRUD ───────────────────────────────────── 

766 # 

767 # Hindsight parity: the 4 mental-model tools mirror Hindsight's 

768 # ``_register_create_mental_model`` / ``_update_mental_model`` / 

769 # ``_delete_mental_model`` MCP surface, with our addition of 

770 # ``memory_list_mental_models`` for inspection. Requires 

771 # :meth:`Astrocyte.set_mental_model_store` to have been called 

772 # (gateway hosts wire this from the configured Postgres store). 

773 

774 @mcp.tool() 

775 async def memory_list_mental_models( 

776 bank_id: str | None = None, 

777 scope: str | None = None, 

778 ) -> str: 

779 """List mental models in a bank. 

780 

781 Mental models are curated, refreshable summaries — e.g. "user 

782 prefers async updates", "Project X status: blocked on review". 

783 Use this to discover what authoritative summaries exist before 

784 composing an answer. 

785 

786 Args: 

787 bank_id: Bank to list. Uses default if omitted. 

788 scope: Optional scope filter (e.g. ``"person:alice"``). 

789 """ 

790 try: 

791 bid = _resolve_bank(bank_id) 

792 models = await brain.list_mental_models( 

793 bank_id=bid, 

794 scope=scope, 

795 context=_resolve_context(), 

796 ) 

797 out = [ 

798 { 

799 "model_id": m.model_id, 

800 "title": m.title, 

801 "scope": m.scope, 

802 "kind": m.kind, 

803 "revision": m.revision, 

804 "refreshed_at": m.refreshed_at.isoformat() if m.refreshed_at else None, 

805 "source_count": len(m.source_ids), 

806 } 

807 for m in models 

808 ] 

809 return json.dumps({"models": out}) 

810 except Exception as exc: 

811 logger.exception("memory_list_mental_models failed") 

812 return json.dumps({"models": [], "error": type(exc).__name__}) 

813 

814 @mcp.tool() 

815 async def memory_create_mental_model( 

816 model_id: str, 

817 title: str, 

818 content: str | None = None, 

819 sections: list[dict] | None = None, 

820 scope: str = "bank", 

821 bank_id: str | None = None, 

822 ) -> str: 

823 """Author a new mental model. 

824 

825 Provide ONE of: 

826 

827 - ``content`` — raw markdown string; the structured doc is 

828 parsed lazily on first refresh (legacy path). 

829 - ``sections`` — list of section dicts matching the 

830 ``StructuredDocument`` JSON shape; the structured doc is 

831 populated immediately so future ``memory_update_mental_model`` 

832 calls can target sections by id (recommended path). 

833 

834 Use ``memory_create_directive`` instead for hard-rule 

835 directives (those are mental models with ``kind="directive"``). 

836 """ 

837 try: 

838 bid = _resolve_bank(bank_id) 

839 model = await brain.create_mental_model( 

840 model_id=model_id, 

841 title=title, 

842 content=content, 

843 sections=sections, 

844 scope=scope, 

845 bank_id=bid, 

846 context=_resolve_context(), 

847 ) 

848 return json.dumps( 

849 { 

850 "model_id": model.model_id, 

851 "revision": model.revision, 

852 "kind": model.kind, 

853 "title": model.title, 

854 } 

855 ) 

856 except Exception as exc: 

857 logger.exception("memory_create_mental_model failed") 

858 return json.dumps({"model_id": None, "error": type(exc).__name__}) 

859 

860 @mcp.tool() 

861 async def memory_update_mental_model( 

862 model_id: str, 

863 operations: list[dict], 

864 bank_id: str | None = None, 

865 ) -> str: 

866 """Apply structured delta operations to an existing mental model. 

867 

868 Operations let you modify a document without re-emitting 

869 unchanged content — invaluable for refresh flows where the 

870 LLM would otherwise drift on sections it didn't intend to 

871 touch. Each operation is a dict with an ``op`` key; see 

872 :mod:`astrocyte.pipeline.delta_ops` for the full schema: 

873 

874 - ``append_block`` / ``insert_block`` / ``replace_block`` / 

875 ``remove_block`` — block-level edits within a section. 

876 - ``add_section`` / ``remove_section`` / ``replace_section_blocks`` 

877 / ``rename_section`` — section-level edits. 

878 

879 Conservative-failure contract: invalid ops (unknown section 

880 ids, out-of-range indices, malformed payloads) drop with a 

881 logged reason in the response's ``skipped`` list; the document 

882 never gets worse than its input. 

883 

884 Returns ``{"changed": bool, "revision": int, "applied": [...], 

885 "skipped": [...]}`` so the caller can audit which ops landed. 

886 """ 

887 try: 

888 bid = _resolve_bank(bank_id) 

889 result = await brain.update_mental_model( 

890 model_id=model_id, 

891 operations=operations, 

892 bank_id=bid, 

893 context=_resolve_context(), 

894 ) 

895 if result is None: 

896 return json.dumps({"changed": False, "error": "model not found"}) 

897 model, summary = result 

898 return json.dumps( 

899 { 

900 "changed": summary.get("changed", False), 

901 "revision": model.revision, 

902 "applied": summary.get("applied", []), 

903 "skipped": summary.get("skipped", []), 

904 } 

905 ) 

906 except Exception as exc: 

907 logger.exception("memory_update_mental_model failed") 

908 return json.dumps({"changed": False, "error": type(exc).__name__}) 

909 

910 @mcp.tool() 

911 async def memory_delete_mental_model( 

912 model_id: str, 

913 bank_id: str | None = None, 

914 ) -> str: 

915 """Soft-delete a mental model. Returns whether it existed.""" 

916 try: 

917 bid = _resolve_bank(bank_id) 

918 ok = await brain.delete_mental_model( 

919 model_id, 

920 bank_id=bid, 

921 context=_resolve_context(), 

922 ) 

923 return json.dumps({"deleted": ok}) 

924 except Exception as exc: 

925 logger.exception("memory_delete_mental_model failed") 

926 return json.dumps({"deleted": False, "error": type(exc).__name__}) 

927 

928 @mcp.tool() 

929 async def memory_refresh_mental_model( 

930 model_id: str, 

931 new_source_ids: list[str], 

932 bank_id: str | None = None, 

933 ) -> str: 

934 """Re-derive a mental model from its sources after new memories have been retained. 

935 

936 Hindsight parity for ``mental_model.refresh()``. When new 

937 memories that pertain to an existing model land via retain, 

938 call this to fold them into the model's ``source_ids`` and 

939 bump the revision. Use ``memory_update_mental_model`` for 

940 targeted edits via delta ops; use this when the model should 

941 be re-derived from an expanded provenance set. 

942 

943 Returns ``{"model_id", "revision", "source_count"}`` on 

944 success or ``{"error": "model not found"}`` if the model 

945 doesn't exist. 

946 """ 

947 try: 

948 bid = _resolve_bank(bank_id) 

949 model = await brain.refresh_mental_model( 

950 model_id, 

951 new_source_ids, 

952 bank_id=bid, 

953 context=_resolve_context(), 

954 ) 

955 if model is None: 

956 return json.dumps({"model_id": model_id, "error": "model not found"}) 

957 return json.dumps( 

958 { 

959 "model_id": model.model_id, 

960 "revision": model.revision, 

961 "source_count": len(model.source_ids), 

962 } 

963 ) 

964 except Exception as exc: 

965 logger.exception("memory_refresh_mental_model failed") 

966 return json.dumps({"model_id": model_id, "error": type(exc).__name__}) 

967 

968 @mcp.tool() 

969 async def memory_create_directive( 

970 rule_text: str, 

971 directive_id: str | None = None, 

972 scope: str = "bank", 

973 bank_id: str | None = None, 

974 ) -> str: 

975 """Author a user-curated hard-rule directive. 

976 

977 Directives are mental models with ``kind="directive"``. They 

978 participate in the same ``search_mental_models`` retrieval as 

979 general / preference models, but the discriminator signals to 

980 the answerer that the rule should be applied as a preference 

981 override rather than as one input among many. 

982 

983 Architecturally replaces the M18a-2 ``directive_compile`` 

984 auto-extraction path that was deprecated in M19 (auto-compressed 

985 directives replicated a −30pp SSP regression because they 

986 overrode original preference nuance). User-authored directives 

987 avoid that failure mode by definition — the user gets exactly 

988 the rule they intended. 

989 """ 

990 try: 

991 bid = _resolve_bank(bank_id) 

992 model = await brain.create_directive( 

993 rule_text=rule_text, 

994 directive_id=directive_id, 

995 scope=scope, 

996 bank_id=bid, 

997 context=_resolve_context(), 

998 ) 

999 return json.dumps( 

1000 { 

1001 "model_id": model.model_id, 

1002 "kind": model.kind, 

1003 "revision": model.revision, 

1004 } 

1005 ) 

1006 except Exception as exc: 

1007 logger.exception("memory_create_directive failed") 

1008 return json.dumps({"model_id": None, "error": type(exc).__name__}) 

1009 

1010 # ── M21 — Observation CRUD ───────────────────────────────────── 

1011 

1012 @mcp.tool() 

1013 async def memory_list_observations( 

1014 bank_id: str | None = None, 

1015 scope: str | None = None, 

1016 trend: str | None = None, 

1017 limit: int = 100, 

1018 ) -> str: 

1019 """List observations in a bank, optionally filtered by trend. 

1020 

1021 Observations are auto-consolidated distillations of raw 

1022 memories (e.g. "user prefers iced coffee in summer"). Each 

1023 carries a computed ``trend`` — one of ``new`` / 

1024 ``strengthening`` / ``stable`` / ``weakening`` / ``stale`` 

1025 derived from the evidence timestamps. Use ``trend`` to filter 

1026 for fresh insights (``new``, ``strengthening``) or to audit 

1027 stale patterns that may no longer apply. 

1028 """ 

1029 try: 

1030 bid = _resolve_bank(bank_id) 

1031 rows = await brain.list_observations( 

1032 bank_id=bid, 

1033 scope=scope, 

1034 trend=trend, 

1035 limit=limit, 

1036 context=_resolve_context(), 

1037 ) 

1038 return json.dumps({"observations": rows}) 

1039 except Exception as exc: 

1040 logger.exception("memory_list_observations failed") 

1041 return json.dumps({"observations": [], "error": type(exc).__name__}) 

1042 

1043 @mcp.tool() 

1044 async def memory_get_observation( 

1045 observation_id: str, 

1046 bank_id: str | None = None, 

1047 ) -> str: 

1048 """Fetch one observation by id. Returns null if not found.""" 

1049 try: 

1050 bid = _resolve_bank(bank_id) 

1051 row = await brain.get_observation( 

1052 observation_id, 

1053 bank_id=bid, 

1054 context=_resolve_context(), 

1055 ) 

1056 return json.dumps({"observation": row}) 

1057 except Exception as exc: 

1058 logger.exception("memory_get_observation failed") 

1059 return json.dumps({"observation": None, "error": type(exc).__name__}) 

1060 

1061 @mcp.tool() 

1062 async def memory_create_observation( 

1063 text: str, 

1064 source_ids: list[str] | None = None, 

1065 scope: str | None = None, 

1066 confidence: float = 0.9, 

1067 bank_id: str | None = None, 

1068 ) -> str: 

1069 """Hand-author an observation (bypasses the autonomous consolidator). 

1070 

1071 Use this when an agent or user wants to assert a fact directly 

1072 without going through retain → consolidate. The observation 

1073 becomes immediately visible to the recall pipeline's 

1074 ``observation`` strategy and to ``search_observations`` in 

1075 the agentic reflect loop. 

1076 """ 

1077 try: 

1078 bid = _resolve_bank(bank_id) 

1079 row = await brain.create_observation( 

1080 text=text, 

1081 source_ids=source_ids, 

1082 scope=scope, 

1083 confidence=confidence, 

1084 bank_id=bid, 

1085 context=_resolve_context(), 

1086 ) 

1087 return json.dumps({"observation": row}) 

1088 except Exception as exc: 

1089 logger.exception("memory_create_observation failed") 

1090 return json.dumps({"observation": None, "error": type(exc).__name__}) 

1091 

1092 @mcp.tool() 

1093 async def memory_delete_observation( 

1094 observation_id: str, 

1095 bank_id: str | None = None, 

1096 ) -> str: 

1097 """Delete one observation from the dedicated observations bank.""" 

1098 try: 

1099 bid = _resolve_bank(bank_id) 

1100 ok = await brain.delete_observation( 

1101 observation_id, 

1102 bank_id=bid, 

1103 context=_resolve_context(), 

1104 ) 

1105 return json.dumps({"deleted": ok}) 

1106 except Exception as exc: 

1107 logger.exception("memory_delete_observation failed") 

1108 return json.dumps({"deleted": False, "error": type(exc).__name__}) 

1109 

1110 # ── memory_health ────────────────────────────────────────────── 

1111 

1112 @mcp.tool() 

1113 async def memory_health() -> str: 

1114 """Check memory system health.""" 

1115 status = await brain.health() 

1116 return json.dumps( 

1117 { 

1118 "healthy": status.healthy, 

1119 "message": status.message, 

1120 "latency_ms": round(status.latency_ms, 2) if status.latency_ms else None, 

1121 } 

1122 ) 

1123 

1124 return mcp 

1125 

1126 

1127# --------------------------------------------------------------------------- 

1128# CLI entry point 

1129# --------------------------------------------------------------------------- 

1130 

1131 

1132def main() -> None: 

1133 """CLI entry point for astrocyte-mcp.""" 

1134 parser = argparse.ArgumentParser( 

1135 prog="astrocyte-mcp", 

1136 description="Astrocyte MCP server — memory tools for AI agents", 

1137 ) 

1138 parser.add_argument( 

1139 "--config", 

1140 default=None, 

1141 help="Path to astrocyte.yaml (default: $ASTROCYTE_CONFIG, else ~/.config/astrocyte/astrocyte.yaml)", 

1142 ) 

1143 parser.add_argument( 

1144 "--transport", 

1145 choices=["stdio", "sse"], 

1146 default="stdio", 

1147 help="MCP transport (default: stdio)", 

1148 ) 

1149 parser.add_argument( 

1150 "--port", 

1151 type=int, 

1152 default=8080, 

1153 help="Port for SSE transport (default: 8080)", 

1154 ) 

1155 args = parser.parse_args() 

1156 

1157 from astrocyte.harness.paths import config_path 

1158 from astrocyte.wiring import build_astrocyte 

1159 

1160 path = Path(args.config).expanduser() if args.config else config_path() 

1161 if not path.is_file(): 

1162 # MCP clients surface stderr when a server fails to start; make it 

1163 # actionable rather than a traceback. 

1164 print( 

1165 f"astrocyte-mcp: no config at {path}.\n" 

1166 " Run `astrocyte setup` to create a local memory store and wire your agents,\n" 

1167 " or pass --config /path/to/astrocyte.yaml.", 

1168 file=sys.stderr, 

1169 ) 

1170 sys.exit(2) 

1171 

1172 # Pipeline, stores and access grants, wired the same way as every other 

1173 # deployment (a bare Astrocyte(config) has no pipeline and stores nothing). 

1174 config = load_config(str(path)) 

1175 brain = build_astrocyte(config) 

1176 

1177 # A stdio server is one process per agent session, launched in the 

1178 # session's directory, so that directory identifies the project. A shared 

1179 # SSE server's cwd identifies nothing, so it keeps requiring a bank. 

1180 fallback = None 

1181 if args.transport == "stdio": 

1182 from astrocyte.harness.project import project_bank 

1183 

1184 fallback = project_bank(os.getcwd()) 

1185 mcp = create_mcp_server(brain, config, default_bank_fallback=fallback) 

1186 

1187 # Run 

1188 if args.transport == "stdio": 

1189 mcp.run(transport="stdio") 

1190 else: 

1191 mcp.run(transport="sse", port=args.port) 

1192 

1193 

1194if __name__ == "__main__": 

1195 main()