feat: new MCP tools — get/list/update drawer, hook settings, export (resolves #635) (#667)

* feat: MCP reliability — inode detection, WAL rotation, metadata cache, search limits

Infrastructure hardening for the MCP server:
- Detect palace DB replacement via inode tracking (repair command support)
- WAL rotation to prevent unbounded WAL growth
- _fetch_all_metadata() + _get_cached_metadata() with 60s TTL for taxonomy/status
- _MAX_RESULTS cap (100) with limit clamping [1, _MAX_RESULTS]
- max_distance parameter for similarity threshold in search
- Handle all notifications/* methods, null arguments, method=None
- Remove duplicate _client_cache = None declarations
- searcher.py max_distance parameter passthrough

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: new MCP tools (get/list/update drawer, hook settings, memories filed), export, normalize

New MCP tools:
- mempalace_get_drawer: fetch single drawer by ID with full content
- mempalace_list_drawers: paginated listing with wing/room filter
- mempalace_update_drawer: update content/wing/room on existing drawers
- mempalace_hook_settings: get/set hook behavior (silent_save, desktop_toast)
- mempalace_memories_filed_away: check latest checkpoint status

Also includes:
- exporter.py: export palace as browsable markdown files
- normalize.py: tool_use/tool_result capture for richer transcript mining
- layers.py: updated for new tool integration
- config.py: hook settings properties (hook_silent_save, hook_desktop_toast)

Depends on PR 3 (reliability) for _MAX_RESULTS, _metadata_cache, WAL logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize.py handles string messages and Read offset type mismatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: params null guard, L2→cosine docs, empty tool_use_map key guard

- Handle explicit null in MCP params (request.get("params") or {})
- Fix search tool description: L2 → cosine distance (collection uses hnsw:space=cosine)
- Guard against empty string key in tool_use_map from malformed JSONL entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: rename ambiguous var 'l' to 'line' (E741 lint)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings (5 issues)

1. min_similarity backwards-compat: convert similarity to distance scale
   (1.0 - similarity) instead of passing raw value as max_distance
2. Restore structured error reporting (error + partial fields) in
   tool_status, tool_list_wings, tool_list_rooms, tool_get_taxonomy
   — reverts silent except:pass that dropped #647 security hardening
3. inode cache: remove falsy-zero short-circuit so missing DB file
   triggers reconnect instead of reusing stale client
4. _fetch_all_metadata: check for empty batch before extending/advancing
   offset to prevent infinite loop on concurrent deletion
5. KG initialization: only override path when --palace is explicit;
   default runs use KnowledgeGraph's built-in default path

Co-authored-by: jphein <jphein@users.noreply.github.com>

---------

Co-authored-by: jp <jp@jphein.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jphein <jphein@users.noreply.github.com>
This commit is contained in:
Ben Sigman
2026-04-11 21:25:04 -07:00
committed by GitHub
parent 58eca5075a
commit 20c8f8e57b
9 changed files with 1429 additions and 164 deletions
+436 -109
View File
@@ -23,12 +23,13 @@ import sys
import json
import logging
import hashlib
import time
from datetime import datetime
from pathlib import Path
from .config import MempalaceConfig, sanitize_name, sanitize_content
from .version import __version__
from .palace import get_collection as _get_collection_from_palace
import chromadb
from .query_sanitizer import sanitize_query
from .searcher import search_memories
from .palace_graph import traverse, find_tunnels, graph_stats
@@ -58,13 +59,17 @@ if _args.palace:
os.environ["MEMPALACE_PALACE_PATH"] = os.path.abspath(_args.palace)
_config = MempalaceConfig()
# Only override KG path when --palace is explicitly provided; otherwise use
# KnowledgeGraph's default (~/.mempalace/knowledge_graph.sqlite3).
if _args.palace:
_kg = KnowledgeGraph(db_path=os.path.join(_config.palace_path, "knowledge_graph.sqlite3"))
else:
_kg = KnowledgeGraph()
_client_cache = None
_collection_cache = None
_palace_db_inode = 0 # inode of chroma.sqlite3 at cache time
# ==================== WRITE-AHEAD LOG ====================
@@ -115,16 +120,43 @@ def _wal_log(operation: str, params: dict, result: dict = None):
logger.error(f"WAL write failed: {e}")
def _get_collection(create=False):
"""Return the configured collection, caching the wrapper between calls."""
global _collection_cache
def _get_client():
"""Return a ChromaDB PersistentClient, reconnecting if the database changed on disk.
Detects palace rebuilds (repair/nuke/purge) by checking the inode of
chroma.sqlite3. A full rebuild replaces the file, changing the inode.
"""
global _client_cache, _collection_cache, _palace_db_inode, _metadata_cache, _metadata_cache_time
db_path = os.path.join(_config.palace_path, "chroma.sqlite3")
try:
if create or _collection_cache is None:
_collection_cache = _get_collection_from_palace(
_config.palace_path,
collection_name=_config.collection_name,
create=create,
current_inode = os.stat(db_path).st_ino
except OSError:
current_inode = 0
if _client_cache is None or current_inode != _palace_db_inode:
_client_cache = chromadb.PersistentClient(path=_config.palace_path)
_collection_cache = None
_metadata_cache = None
_metadata_cache_time = 0
_palace_db_inode = current_inode
return _client_cache
def _get_collection(create=False):
"""Return the ChromaDB collection, caching the client between calls."""
global _collection_cache, _metadata_cache, _metadata_cache_time
try:
client = _get_client()
if create:
_collection_cache = client.get_or_create_collection(
_config.collection_name, metadata={"hnsw:space": "cosine"}
)
_metadata_cache = None
_metadata_cache_time = 0
elif _collection_cache is None:
_collection_cache = client.get_collection(_config.collection_name)
_metadata_cache = None
_metadata_cache_time = 0
return _collection_cache
except Exception:
return None
@@ -137,6 +169,49 @@ def _no_palace():
}
# ==================== HELPERS ====================
def _fetch_all_metadata(col, where=None):
"""Paginate col.get() to avoid the 10K silent truncation limit."""
total = col.count()
all_meta = []
offset = 0
while offset < total:
kwargs = {"include": ["metadatas"], "limit": 1000, "offset": offset}
if where:
kwargs["where"] = where
batch = col.get(**kwargs)
if not batch["metadatas"]:
break
all_meta.extend(batch["metadatas"])
offset += len(batch["metadatas"])
return all_meta
_metadata_cache = None
_metadata_cache_time = 0
_METADATA_CACHE_TTL = 5.0 # seconds
_MAX_RESULTS = 100 # upper bound for search/list limit params
def _get_cached_metadata(col, where=None):
"""Return cached metadata if fresh, else fetch and cache."""
global _metadata_cache, _metadata_cache_time
now = time.time()
if (
where is None
and _metadata_cache is not None
and (now - _metadata_cache_time) < _METADATA_CACHE_TTL
):
return _metadata_cache
result = _fetch_all_metadata(col, where=where)
if where is None:
_metadata_cache = result
_metadata_cache_time = now
return result
# ==================== READ TOOLS ====================
@@ -147,24 +222,6 @@ def tool_status():
count = col.count()
wings = {}
rooms = {}
batch_size = 5000
offset = 0
error_info = None
while True:
try:
batch = col.get(include=["metadatas"], limit=batch_size, offset=offset)
rows = batch["metadatas"]
for m in rows:
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
wings[w] = wings.get(w, 0) + 1
rooms[r] = rooms.get(r, 0) + 1
offset += len(rows)
if len(rows) < batch_size:
break
except Exception as e:
error_info = f"Partial result, failed at offset {offset}: {str(e)}"
break
result = {
"total_drawers": count,
"wings": wings,
@@ -173,8 +230,16 @@ def tool_status():
"protocol": PALACE_PROTOCOL,
"aaak_dialect": AAAK_SPEC,
}
if error_info:
result["error"] = error_info
try:
all_meta = _get_cached_metadata(col)
for m in all_meta:
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
wings[w] = wings.get(w, 0) + 1
rooms[r] = rooms.get(r, 0) + 1
except Exception as e:
logger.exception("tool_status metadata fetch failed")
result["error"] = str(e)
result["partial"] = True
return result
@@ -217,29 +282,17 @@ def tool_list_wings():
if not col:
return _no_palace()
wings = {}
batch_size = 5000
offset = 0
result = {"wings": wings}
try:
col.count() # verify collection is accessible
all_meta = _get_cached_metadata(col)
for m in all_meta:
w = m.get("wing", "unknown")
wings[w] = wings.get(w, 0) + 1
except Exception as e:
return {"wings": {}, "error": str(e)}
while True:
try:
batch = col.get(include=["metadatas"], limit=batch_size, offset=offset)
rows = batch["metadatas"]
for m in rows:
w = m.get("wing", "unknown")
wings[w] = wings.get(w, 0) + 1
offset += len(rows)
if len(rows) < batch_size:
break
except Exception as e:
return {
"wings": wings,
"error": f"Partial result, failed at offset {offset}: {str(e)}",
"partial": True,
}
return {"wings": wings}
logger.exception("tool_list_wings metadata fetch failed")
result["error"] = str(e)
result["partial"] = True
return result
def tool_list_rooms(wing: str = None):
@@ -247,34 +300,18 @@ def tool_list_rooms(wing: str = None):
if not col:
return _no_palace()
rooms = {}
batch_size = 5000
offset = 0
where = {"wing": wing} if wing else None
result = {"wing": wing or "all", "rooms": rooms}
try:
col.count() # verify collection is accessible
where = {"wing": wing} if wing else None
all_meta = _fetch_all_metadata(col, where=where)
for m in all_meta:
r = m.get("room", "unknown")
rooms[r] = rooms.get(r, 0) + 1
except Exception as e:
return {"wing": wing or "all", "rooms": {}, "error": str(e)}
while True:
try:
kwargs = {"include": ["metadatas"], "limit": batch_size, "offset": offset}
if where:
kwargs["where"] = where
batch = col.get(**kwargs)
rows = batch["metadatas"]
for m in rows:
r = m.get("room", "unknown")
rooms[r] = rooms.get(r, 0) + 1
offset += len(rows)
if len(rows) < batch_size:
break
except Exception as e:
return {
"wing": wing or "all",
"rooms": rooms,
"error": f"Partial result, failed at offset {offset}: {str(e)}",
"partial": True,
}
return {"wing": wing or "all", "rooms": rooms}
logger.exception("tool_list_rooms metadata fetch failed")
result["error"] = str(e)
result["partial"] = True
return result
def tool_get_taxonomy():
@@ -282,38 +319,31 @@ def tool_get_taxonomy():
if not col:
return _no_palace()
taxonomy = {}
batch_size = 5000
offset = 0
result = {"taxonomy": taxonomy}
try:
col.count() # verify collection is accessible
all_meta = _get_cached_metadata(col)
for m in all_meta:
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
if w not in taxonomy:
taxonomy[w] = {}
taxonomy[w][r] = taxonomy[w].get(r, 0) + 1
except Exception as e:
return {"taxonomy": {}, "error": str(e)}
while True:
try:
batch = col.get(include=["metadatas"], limit=batch_size, offset=offset)
rows = batch["metadatas"]
for m in rows:
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
if w not in taxonomy:
taxonomy[w] = {}
taxonomy[w][r] = taxonomy[w].get(r, 0) + 1
offset += len(rows)
if len(rows) < batch_size:
break
except Exception as e:
return {
"taxonomy": taxonomy,
"error": f"Partial result, failed at offset {offset}: {str(e)}",
"partial": True,
}
return {"taxonomy": taxonomy}
logger.exception("tool_get_taxonomy metadata fetch failed")
result["error"] = str(e)
result["partial"] = True
return result
def tool_search(
query: str, limit: int = 5, wing: str = None, room: str = None, context: str = None
query: str, limit: int = 5, wing: str = None, room: str = None,
max_distance: float = 1.5, min_similarity: float = None, context: str = None,
):
limit = max(1, min(limit, 50))
limit = max(1, min(limit, _MAX_RESULTS))
# Backwards compat: accept old name
# Backwards compat: convert old similarity scale (higher=stricter) to
# distance scale (lower=stricter). Similarity 0.8 → distance 0.2.
dist = (1.0 - min_similarity) if min_similarity is not None else max_distance
# Mitigate system prompt contamination (Issue #333)
sanitized = sanitize_query(query)
result = search_memories(
@@ -322,6 +352,7 @@ def tool_search(
wing=wing,
room=room,
n_results=limit,
max_distance=dist,
)
# Attach sanitizer metadata for transparency
if sanitized["was_sanitized"]:
@@ -410,6 +441,7 @@ def tool_add_drawer(
wing: str, room: str, content: str, source_file: str = None, added_by: str = "mcp"
):
"""File verbatim content into a wing/room. Checks for duplicates first."""
global _metadata_cache
try:
wing = sanitize_name(wing, "wing")
room = sanitize_name(room, "room")
@@ -458,6 +490,7 @@ def tool_add_drawer(
}
],
)
_metadata_cache = None
logger.info(f"Filed drawer: {drawer_id}{wing}/{room}")
return {"success": True, "drawer_id": drawer_id, "wing": wing, "room": room}
except Exception as e:
@@ -466,6 +499,7 @@ def tool_add_drawer(
def tool_delete_drawer(drawer_id: str):
"""Delete a single drawer by ID."""
global _metadata_cache
col = _get_collection()
if not col:
return _no_palace()
@@ -487,12 +521,150 @@ def tool_delete_drawer(drawer_id: str):
try:
col.delete(ids=[drawer_id])
_metadata_cache = None
logger.info(f"Deleted drawer: {drawer_id}")
return {"success": True, "drawer_id": drawer_id}
except Exception as e:
return {"success": False, "error": str(e)}
def tool_get_drawer(drawer_id: str):
"""Fetch a single drawer by ID. Returns full content and metadata."""
col = _get_collection()
if not col:
return _no_palace()
try:
result = col.get(ids=[drawer_id], include=["documents", "metadatas"])
if not result["ids"]:
return {"error": f"Drawer not found: {drawer_id}"}
meta = result["metadatas"][0]
doc = result["documents"][0]
return {
"drawer_id": drawer_id,
"content": doc,
"wing": meta.get("wing", ""),
"room": meta.get("room", ""),
"metadata": meta,
}
except Exception as e:
return {"error": str(e)}
def tool_list_drawers(wing: str = None, room: str = None, limit: int = 20, offset: int = 0):
"""List drawers with pagination. Optional wing/room filter."""
limit = max(1, min(limit, _MAX_RESULTS))
offset = max(0, offset)
col = _get_collection()
if not col:
return _no_palace()
try:
where = None
conditions = []
if wing:
conditions.append({"wing": wing})
if room:
conditions.append({"room": room})
if len(conditions) == 1:
where = conditions[0]
elif len(conditions) > 1:
where = {"$and": conditions}
kwargs = {"include": ["documents", "metadatas"], "limit": limit, "offset": offset}
if where:
kwargs["where"] = where
result = col.get(**kwargs)
drawers = []
for i, did in enumerate(result["ids"]):
meta = result["metadatas"][i]
doc = result["documents"][i]
drawers.append(
{
"drawer_id": did,
"wing": meta.get("wing", ""),
"room": meta.get("room", ""),
"content_preview": doc[:200] + "..." if len(doc) > 200 else doc,
}
)
return {
"drawers": drawers,
"count": len(drawers),
"offset": offset,
"limit": limit,
}
except Exception as e:
return {"error": str(e)}
def tool_update_drawer(drawer_id: str, content: str = None, wing: str = None, room: str = None):
"""Update an existing drawer's content and/or metadata."""
global _metadata_cache
if content is None and wing is None and room is None:
return {"success": True, "drawer_id": drawer_id, "noop": True}
col = _get_collection()
if not col:
return _no_palace()
try:
existing = col.get(ids=[drawer_id], include=["documents", "metadatas"])
if not existing["ids"]:
return {"success": False, "error": f"Drawer not found: {drawer_id}"}
old_meta = existing["metadatas"][0]
old_doc = existing["documents"][0]
new_doc = old_doc
if content is not None:
try:
new_doc = sanitize_content(content)
except ValueError as e:
return {"success": False, "error": str(e)}
new_meta = dict(old_meta)
if wing is not None:
try:
new_meta["wing"] = sanitize_name(wing, "wing")
except ValueError as e:
return {"success": False, "error": str(e)}
if room is not None:
try:
new_meta["room"] = sanitize_name(room, "room")
except ValueError as e:
return {"success": False, "error": str(e)}
_wal_log(
"update_drawer",
{
"drawer_id": drawer_id,
"old_wing": old_meta.get("wing", ""),
"old_room": old_meta.get("room", ""),
"new_wing": new_meta.get("wing", ""),
"new_room": new_meta.get("room", ""),
"content_changed": content is not None,
"content_preview": new_doc[:200] if content is not None else None,
},
)
update_kwargs = {"ids": [drawer_id]}
if content is not None:
update_kwargs["documents"] = [new_doc]
update_kwargs["metadatas"] = [new_meta]
col.update(**update_kwargs)
_metadata_cache = None
logger.info(f"Updated drawer: {drawer_id}")
return {
"success": True,
"drawer_id": drawer_id,
"wing": new_meta.get("wing", ""),
"room": new_meta.get("room", ""),
}
except Exception as e:
return {"success": False, "error": str(e)}
# ==================== KNOWLEDGE GRAPH ====================
@@ -691,6 +863,71 @@ def tool_diary_read(agent_name: str, last_n: int = 10):
return {"error": "Failed to read diary entries"}
def tool_hook_settings(silent_save: bool = None, desktop_toast: bool = None):
"""
Get or set hook behavior settings.
- silent_save: True = stop hook saves directly (no MCP clutter),
False = legacy blocking MCP calls. Default: True.
- desktop_toast: True = show notify-send desktop toast on save,
False = terminal-only notification. Default: False.
Call with no arguments to see current settings.
"""
from .config import MempalaceConfig
try:
config = MempalaceConfig()
except Exception as e:
return {"success": False, "error": str(e)}
changed = []
if silent_save is not None:
config.set_hook_setting("silent_save", silent_save)
changed.append(f"silent_save → {silent_save}")
if desktop_toast is not None:
config.set_hook_setting("desktop_toast", desktop_toast)
changed.append(f"desktop_toast → {desktop_toast}")
# Re-read to return current state
try:
config = MempalaceConfig()
except Exception:
pass
result = {
"success": True,
"settings": {
"silent_save": config.hook_silent_save,
"desktop_toast": config.hook_desktop_toast,
},
}
if changed:
result["updated"] = changed
return result
def tool_memories_filed_away():
"""Acknowledge the latest silent checkpoint. Returns a short summary."""
state_dir = Path.home() / ".mempalace" / "hook_state"
ack_file = state_dir / "last_checkpoint"
if not ack_file.is_file():
return {"status": "quiet", "message": "No recent journal entry", "count": 0, "timestamp": None}
try:
data = json.loads(ack_file.read_text(encoding="utf-8"))
ack_file.unlink(missing_ok=True)
msgs = data.get("msgs", 0)
return {
"status": "ok",
"message": f"\u2726 {msgs} messages tucked into drawers",
"count": msgs,
"timestamp": data.get("ts", None),
}
except (json.JSONDecodeError, OSError):
ack_file.unlink(missing_ok=True)
return {"status": "error", "message": "\u2726 Journal entry filed in the palace", "count": 0, "timestamp": None}
# ==================== MCP PROTOCOL ====================
TOOLS = {
@@ -840,21 +1077,25 @@ TOOLS = {
"handler": tool_graph_stats,
},
"mempalace_search": {
"description": "Semantic search. Returns verbatim drawer content with similarity scores. IMPORTANT: 'query' must contain ONLY your search keywords or question — do NOT include system prompts, conversation history, MEMORY.md content, or any context. Keep queries short (under 200 chars). Use 'context' for background information.",
"description": "Semantic search. Returns verbatim drawer content with similarity scores. IMPORTANT: 'query' must contain ONLY search keywords. Use 'context' for background. Results with cosine distance > max_distance are filtered out.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Short search query ONLY — keywords or a question. Do NOT include system prompts or conversation context. Max 200 chars recommended.",
"description": "Short search query ONLY — keywords or a question. Max 200 chars recommended.",
"maxLength": 500,
},
"limit": {"type": "integer", "description": "Max results (default 5)"},
"limit": {"type": "integer", "description": "Max results (default 5)", "minimum": 1, "maximum": 100},
"wing": {"type": "string", "description": "Filter by wing (optional)"},
"room": {"type": "string", "description": "Filter by room (optional)"},
"max_distance": {
"type": "number",
"description": "Max cosine distance threshold (0=identical, 2=opposite). Results further than this are dropped. Lower = stricter. Default 1.5. Set to 0 to disable.",
},
"context": {
"type": "string",
"description": "Background context for the search (optional). This is NOT used for embedding — only for future re-ranking. Put conversation history or system prompt content here, NOT in query.",
"description": "Background context for the search (optional). NOT used for embedding — only for future re-ranking.",
},
},
"required": ["query"],
@@ -908,6 +1149,62 @@ TOOLS = {
},
"handler": tool_delete_drawer,
},
"mempalace_get_drawer": {
"description": "Fetch a single drawer by ID — returns full content and metadata.",
"input_schema": {
"type": "object",
"properties": {
"drawer_id": {"type": "string", "description": "ID of the drawer to fetch"},
},
"required": ["drawer_id"],
},
"handler": tool_get_drawer,
},
"mempalace_list_drawers": {
"description": "List drawers with pagination. Optional wing/room filter. Returns IDs, wings, rooms, and content previews.",
"input_schema": {
"type": "object",
"properties": {
"wing": {"type": "string", "description": "Filter by wing (optional)"},
"room": {"type": "string", "description": "Filter by room (optional)"},
"limit": {
"type": "integer",
"description": "Max results per page (default 20, max 100)",
"minimum": 1,
"maximum": 100,
},
"offset": {
"type": "integer",
"description": "Offset for pagination (default 0)",
"minimum": 0,
},
},
},
"handler": tool_list_drawers,
},
"mempalace_update_drawer": {
"description": "Update an existing drawer's content and/or metadata (wing, room). Fetches existing drawer first; returns error if not found.",
"input_schema": {
"type": "object",
"properties": {
"drawer_id": {"type": "string", "description": "ID of the drawer to update"},
"content": {
"type": "string",
"description": "New content (optional — omit to keep existing)",
},
"wing": {
"type": "string",
"description": "New wing (optional — omit to keep existing)",
},
"room": {
"type": "string",
"description": "New room (optional — omit to keep existing)",
},
},
"required": ["drawer_id"],
},
"handler": tool_update_drawer,
},
"mempalace_diary_write": {
"description": "Write to your personal agent diary in AAAK format. Your observations, thoughts, what you worked on, what matters. Each agent has their own diary with full history. Write in AAAK for compression — e.g. 'SESSION:2026-04-04|built.palace.graph+diary.tools|ALC.req:agent.diaries.in.aaak|★★★'. Use entity codes from the AAAK spec.",
"input_schema": {
@@ -948,6 +1245,32 @@ TOOLS = {
},
"handler": tool_diary_read,
},
"mempalace_hook_settings": {
"description": (
"Get or set hook behavior. silent_save: True = save directly "
"(no MCP clutter), False = legacy blocking. desktop_toast: "
"True = show desktop notification. Call with no args to view."
),
"input_schema": {
"type": "object",
"properties": {
"silent_save": {
"type": "boolean",
"description": "True = silent direct save, False = blocking MCP calls",
},
"desktop_toast": {
"type": "boolean",
"description": "True = show desktop toast via notify-send",
},
},
},
"handler": tool_hook_settings,
},
"mempalace_memories_filed_away": {
"description": "Check if a recent palace checkpoint was saved. Returns message count and timestamp.",
"input_schema": {"type": "object", "properties": {}},
"handler": tool_memories_filed_away,
},
}
@@ -960,8 +1283,8 @@ SUPPORTED_PROTOCOL_VERSIONS = [
def handle_request(request):
method = request.get("method", "")
params = request.get("params", {})
method = request.get("method") or ""
params = request.get("params") or {}
req_id = request.get("id")
if method == "initialize":
@@ -982,7 +1305,8 @@ def handle_request(request):
}
elif method == "ping":
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
elif method == "notifications/initialized":
elif method.startswith("notifications/"):
# Notifications (no id) never get a response per JSON-RPC spec
return None
elif method == "tools/list":
return {
@@ -1040,6 +1364,9 @@ def handle_request(request):
"error": {"code": -32000, "message": "Internal tool error"},
}
# Notifications (missing id) must never get a response
if req_id is None:
return None
return {
"jsonrpc": "2.0",
"id": req_id,