Skip to content

Commit 9454b92

Browse files
committed
feat(mcp): add multi-user, multi-namespace, and session support to MCP tools
Add optional user_id, namespace_id, and session_id parameters to all MCP tools. Namespace is resolved per-request with lazy initialization and caching. Trajectory and guideline writes inject user/session metadata. Retrieval stays broad (no user/session filtering) for backward compatibility.
1 parent 7364300 commit 9454b92

2 files changed

Lines changed: 271 additions & 47 deletions

File tree

altk_evolve/frontend/mcp/mcp_server.py

Lines changed: 130 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
logger = logging.getLogger("entities-mcp")
2828

2929
_client = None
30-
_namespace_initialized = False
30+
_initialized_namespaces: set[str] = set()
3131
_client_init_lock = threading.Lock()
3232

3333
# Need to configure FastAPI separately and mount FastMCP on it
@@ -93,38 +93,70 @@ async def serve_spa(request: Request, catchall: str = ""):
9393
def get_client() -> EvolveClient:
9494
"""Get the EvolveClient singleton with lazy initialization.
9595
96-
Initializes the client and ensures namespace exists on first access.
96+
Initializes the client and ensures the default namespace exists on first access.
9797
This avoids the FastMCP SSE lifespan initialization race condition.
9898
"""
99-
global _client, _namespace_initialized
99+
global _client
100100

101101
with _client_init_lock:
102102
if _client is None:
103103
logger.info("Initializing Evolve client...")
104104
_client = EvolveClient()
105105
logger.info("Evolve client initialized")
106106

107-
if not _namespace_initialized:
108-
logger.info("Ensuring namespace exists...")
107+
default_ns = evolve_config.namespace_id
108+
if default_ns not in _initialized_namespaces:
109+
logger.info(f"Ensuring default namespace '{default_ns}' exists...")
109110
try:
110-
_client.ensure_namespace(evolve_config.namespace_id)
111-
logger.info(f"Namespace '{evolve_config.namespace_id}' is ready")
111+
_client.ensure_namespace(default_ns)
112+
_initialized_namespaces.add(default_ns)
113+
logger.info(f"Namespace '{default_ns}' is ready")
112114
except Exception as e:
113-
logger.error(f"Failed to ensure namespace '{evolve_config.namespace_id}': {e}")
115+
logger.error(f"Failed to ensure namespace '{default_ns}': {e}")
114116
raise
115-
_namespace_initialized = True
116-
logger.info("Namespace initialization complete")
117117

118118
return _client
119119

120120

121-
def get_entities_logic(task: str, entity_type: str = "guideline", include_public: bool = False, limit: int = 10) -> str:
122-
"""Implementation logic for get_entities tool."""
123-
logger.info(f"Getting entities of type '{entity_type}' for task: {task} (include_public={include_public})")
121+
def _resolve_namespace(namespace_id: str | None) -> str:
122+
"""Resolve the effective namespace, ensuring it exists before use."""
123+
resolved = namespace_id or evolve_config.namespace_id
124+
if resolved not in _initialized_namespaces:
125+
logger.info(f"Ensuring namespace '{resolved}' exists (first use)...")
126+
try:
127+
get_client().ensure_namespace(resolved)
128+
_initialized_namespaces.add(resolved)
129+
logger.info(f"Namespace '{resolved}' is ready")
130+
except Exception as e:
131+
logger.error(f"Failed to ensure namespace '{resolved}': {e}")
132+
raise
133+
return resolved
134+
135+
136+
def get_entities_logic(
137+
task: str,
138+
entity_type: str = "guideline",
139+
include_public: bool = False,
140+
limit: int = 10,
141+
user_id: str | None = None,
142+
namespace_id: str | None = None,
143+
session_id: str | None = None,
144+
) -> str:
145+
"""Implementation logic for get_entities tool.
146+
147+
Retrieval is intentionally broad: user_id and session_id are NOT used as
148+
hard filters so that shared/older guidelines remain visible. They are
149+
accepted here for future opt-in narrowing but currently only logged.
150+
"""
151+
resolved_ns = _resolve_namespace(namespace_id)
152+
logger.info(
153+
f"Getting entities of type '{entity_type}' for task: {task} "
154+
f"(namespace={resolved_ns}, user_id={user_id}, session_id={session_id}, include_public={include_public})"
155+
)
124156
client = get_client()
125157

126158
private_results = client.search_entities(
127-
namespace_id=evolve_config.namespace_id,
159+
namespace_id=resolved_ns,
128160
query=task,
129161
filters={"type": entity_type},
130162
limit=limit,
@@ -137,13 +169,10 @@ def get_entities_logic(task: str, entity_type: str = "guideline", include_public
137169
response_lines.append(f"{i}. {entity.content}")
138170

139171
if include_public:
140-
# Exclude the caller's own namespace: those entities are already in private_results,
141-
# and using a bare entity ID for cross-namespace dedup risks false-positives when
142-
# different namespaces share the same auto-assigned numeric IDs.
143172
public_results = client.get_public_entities(
144173
query=task,
145174
entity_type=entity_type,
146-
exclude_namespace_ids=[evolve_config.namespace_id],
175+
exclude_namespace_ids=[resolved_ns],
147176
limit=limit,
148177
)
149178
private_ids: set[str] = {e.id for e in private_results}
@@ -161,7 +190,15 @@ def get_entities_logic(task: str, entity_type: str = "guideline", include_public
161190

162191

163192
@mcp.tool()
164-
def get_entities(task: str, entity_type: str = "guideline", include_public: bool = False, limit: int = 10) -> str:
193+
def get_entities(
194+
task: str,
195+
entity_type: str = "guideline",
196+
include_public: bool = False,
197+
limit: int = 10,
198+
user_id: str | None = None,
199+
namespace_id: str | None = None,
200+
session_id: str | None = None,
201+
) -> str:
165202
"""
166203
Get relevant entities for a given task, filtered by type.
167204
Provide a task description and receive applicable best practices, guidelines, or policies.
@@ -171,64 +208,105 @@ def get_entities(task: str, entity_type: str = "guideline", include_public: bool
171208
entity_type: The type of entities to retrieve (e.g., 'guideline', 'policy'). Defaults to 'guideline'.
172209
include_public: If True, also include public entities from all namespaces. Defaults to False.
173210
limit: Maximum number of results to return from each source (private and public). Defaults to 10.
211+
user_id: Optional caller user ID. Logged for attribution; does not filter results.
212+
namespace_id: Optional namespace override. Falls back to the configured default.
213+
session_id: Optional session/thread ID. Logged for attribution; does not filter results.
174214
"""
175-
return get_entities_logic(task, entity_type, include_public, limit)
215+
return get_entities_logic(task, entity_type, include_public, limit, user_id, namespace_id, session_id)
176216

177217

178218
@mcp.tool()
179-
def get_guidelines(task: str) -> str:
219+
def get_guidelines(
220+
task: str,
221+
user_id: str | None = None,
222+
namespace_id: str | None = None,
223+
session_id: str | None = None,
224+
) -> str:
180225
"""
181226
Get relevant guidelines for a given task.
182227
Provide a task description and receive applicable best practices and guidelines.
183228
This tool is maintained for backward compatibility. Use 'get_entities' for more generic queries.
184229
185230
Args:
186231
task: A description of the task you want guidelines for
232+
user_id: Optional caller user ID. Logged for attribution; does not filter results.
233+
namespace_id: Optional namespace override. Falls back to the configured default.
234+
session_id: Optional session/thread ID. Logged for attribution; does not filter results.
187235
"""
188-
return get_entities_logic(task, "guideline")
236+
return get_entities_logic(task, "guideline", user_id=user_id, namespace_id=namespace_id, session_id=session_id)
189237

190238

191239
@mcp.tool()
192-
def save_trajectory(trajectory_data: str, task_id: str | None = None, owner_id: str | None = None) -> list[RecordedEntity]:
240+
def save_trajectory(
241+
trajectory_data: str,
242+
task_id: str | None = None,
243+
owner_id: str | None = None,
244+
user_id: str | None = None,
245+
namespace_id: str | None = None,
246+
session_id: str | None = None,
247+
) -> list[RecordedEntity]:
193248
"""
194249
Save the full agent trajectory to the Entity DB and generate guidelines
195250
196251
Args:
197252
trajectory_data: A JSON formatted OpenAI conversation.
198253
task_id: Optional identifier for the task.
199254
owner_id: Optional user ID to record as the owner of generated guidelines.
255+
user_id: Optional caller user ID. Attached as metadata to trajectory and guideline entities.
256+
namespace_id: Optional namespace override. Falls back to the configured default.
257+
session_id: Optional session/thread ID. Attached as metadata to trajectory and guideline entities.
200258
"""
259+
resolved_ns = _resolve_namespace(namespace_id)
260+
# Prefer explicit user_id; fall back to owner_id for backward compatibility
261+
effective_user_id = user_id or owner_id
201262
task_id = task_id or str(uuid.uuid4())
263+
264+
logger.info(f"Saving trajectory: namespace={resolved_ns}, user_id={effective_user_id}, session_id={session_id}, task_id={task_id}")
265+
202266
entities = []
203267
messages = json.loads(trajectory_data)
268+
trajectory_metadata_base: dict = {"task_id": task_id}
269+
if effective_user_id:
270+
trajectory_metadata_base["user_id"] = effective_user_id
271+
if session_id:
272+
trajectory_metadata_base["session_id"] = session_id
273+
204274
for message in messages:
205275
entities.append(
206276
Entity(
207277
type="trajectory",
208278
content=message["content"] if isinstance(message["content"], str) else str(message["content"]),
209279
metadata={
210-
"task_id": task_id,
280+
**trajectory_metadata_base,
211281
"message": message,
212282
},
213283
)
214284
)
215285

216286
get_client().update_entities(
217-
namespace_id=evolve_config.namespace_id,
287+
namespace_id=resolved_ns,
218288
entities=entities,
219289
enable_conflict_resolution=False,
220290
)
221291
results = generate_guidelines(messages)
222292

293+
guideline_metadata_base: dict = {
294+
"source_task_id": task_id,
295+
"creation_mode": "auto-mcp",
296+
}
297+
if effective_user_id:
298+
guideline_metadata_base["owner_id"] = effective_user_id
299+
guideline_metadata_base["user_id"] = effective_user_id
300+
if session_id:
301+
guideline_metadata_base["session_id"] = session_id
302+
223303
guideline_entities = [
224304
Entity(
225305
type="guideline",
226306
content=guideline.content,
227307
metadata={
308+
**guideline_metadata_base,
228309
"task_description": result.task_description,
229-
"source_task_id": task_id,
230-
"creation_mode": "auto-mcp",
231-
**({"owner_id": owner_id} if owner_id else {}),
232310
"category": guideline.category,
233311
"rationale": guideline.rationale,
234312
"trigger": guideline.trigger,
@@ -240,13 +318,13 @@ def save_trajectory(trajectory_data: str, task_id: str | None = None, owner_id:
240318
]
241319
if guideline_entities:
242320
get_client().update_entities(
243-
namespace_id=evolve_config.namespace_id,
321+
namespace_id=resolved_ns,
244322
entities=guideline_entities,
245323
enable_conflict_resolution=True,
246324
)
247325

248326
return get_client().search_entities(
249-
namespace_id=evolve_config.namespace_id,
327+
namespace_id=resolved_ns,
250328
filters={"type": "trajectory", "metadata.task_id": task_id},
251329
limit=1000,
252330
)
@@ -260,6 +338,7 @@ def create_entity(
260338
enable_conflict_resolution: bool = False,
261339
owner_id: str | None = None,
262340
visibility: str = "private",
341+
namespace_id: str | None = None,
263342
) -> str:
264343
"""
265344
Create a single entity in the namespace.
@@ -271,11 +350,13 @@ def create_entity(
271350
enable_conflict_resolution: If True, uses LLM to check for conflicts with existing entities
272351
owner_id: Optional user ID to record as the owner of this entity
273352
visibility: Visibility of the entity — 'private' (default) or 'public'
353+
namespace_id: Optional namespace override. Falls back to the configured default.
274354
275355
Returns:
276356
JSON string with the entity update details (ADD/UPDATE/DELETE/NONE) and entity ID
277357
"""
278-
logger.info(f"Creating entity of type: {entity_type}")
358+
resolved_ns = _resolve_namespace(namespace_id)
359+
logger.info(f"Creating entity of type: {entity_type} in namespace: {resolved_ns}")
279360
try:
280361
if visibility not in ("private", "public"):
281362
return json.dumps({"error": f"Invalid visibility '{visibility}': must be 'private' or 'public'"})
@@ -312,7 +393,7 @@ def create_entity(
312393
entity = Entity(type=entity_type, content=content, metadata=metadata_dict)
313394

314395
updates = get_client().update_entities(
315-
namespace_id=evolve_config.namespace_id, entities=[entity], enable_conflict_resolution=enable_conflict_resolution
396+
namespace_id=resolved_ns, entities=[entity], enable_conflict_resolution=enable_conflict_resolution
316397
)
317398

318399
if updates:
@@ -332,22 +413,24 @@ def create_entity(
332413

333414

334415
@mcp.tool()
335-
def publish_entity(entity_id: str, user_id: str | None = None) -> str:
416+
def publish_entity(entity_id: str, user_id: str | None = None, namespace_id: str | None = None) -> str:
336417
"""
337418
Make an entity publicly visible to all users.
338419
339420
Args:
340421
entity_id: The ID of the entity to publish
341422
user_id: Caller identity; must match the entity's owner_id if one is set
423+
namespace_id: Optional namespace override. Falls back to the configured default.
342424
343425
Returns:
344426
JSON string with the updated entity, or an error message
345427
"""
346-
logger.info(f"publish entity={entity_id} owner_present={user_id is not None} namespace={evolve_config.namespace_id}")
428+
resolved_ns = _resolve_namespace(namespace_id)
429+
logger.info(f"publish entity={entity_id} owner_present={user_id is not None} namespace={resolved_ns}")
347430
try:
348431
from datetime import datetime, UTC
349432

350-
entity = get_client().get_entity_by_id(namespace_id=evolve_config.namespace_id, entity_id=entity_id)
433+
entity = get_client().get_entity_by_id(namespace_id=resolved_ns, entity_id=entity_id)
351434
if entity is None:
352435
return json.dumps({"error": f"Entity {entity_id} not found"})
353436

@@ -362,7 +445,7 @@ def publish_entity(entity_id: str, user_id: str | None = None) -> str:
362445
if user_id is not None:
363446
metadata_updates["owner_id"] = user_id
364447
updated = get_client().patch_entity_metadata(
365-
namespace_id=evolve_config.namespace_id,
448+
namespace_id=resolved_ns,
366449
entity_id=entity_id,
367450
metadata_updates=metadata_updates,
368451
)
@@ -372,20 +455,22 @@ def publish_entity(entity_id: str, user_id: str | None = None) -> str:
372455

373456

374457
@mcp.tool()
375-
def unpublish_entity(entity_id: str, user_id: str | None = None) -> str:
458+
def unpublish_entity(entity_id: str, user_id: str | None = None, namespace_id: str | None = None) -> str:
376459
"""
377460
Revert an entity to private visibility.
378461
379462
Args:
380463
entity_id: The ID of the entity to unpublish
381464
user_id: Caller identity; must match the entity's owner_id if one is set
465+
namespace_id: Optional namespace override. Falls back to the configured default.
382466
383467
Returns:
384468
JSON string with the updated entity, or an error message
385469
"""
386-
logger.info(f"unpublish entity={entity_id} namespace={evolve_config.namespace_id}")
470+
resolved_ns = _resolve_namespace(namespace_id)
471+
logger.info(f"unpublish entity={entity_id} namespace={resolved_ns}")
387472
try:
388-
entity = get_client().get_entity_by_id(namespace_id=evolve_config.namespace_id, entity_id=entity_id)
473+
entity = get_client().get_entity_by_id(namespace_id=resolved_ns, entity_id=entity_id)
389474
if entity is None:
390475
return json.dumps({"error": f"Entity {entity_id} not found"})
391476

@@ -394,7 +479,7 @@ def unpublish_entity(entity_id: str, user_id: str | None = None) -> str:
394479
return json.dumps({"error": "Permission denied: caller is not the owner of this entity"})
395480

396481
updated = get_client().patch_entity_metadata(
397-
namespace_id=evolve_config.namespace_id,
482+
namespace_id=resolved_ns,
398483
entity_id=entity_id,
399484
metadata_updates={"visibility": "private", "published_at": None},
400485
)
@@ -404,21 +489,22 @@ def unpublish_entity(entity_id: str, user_id: str | None = None) -> str:
404489

405490

406491
@mcp.tool()
407-
def delete_entity(entity_id: str) -> str:
492+
def delete_entity(entity_id: str, namespace_id: str | None = None) -> str:
408493
"""
409494
Delete a specific entity by its ID.
410495
411496
Args:
412497
entity_id: The unique identifier of the entity to delete
498+
namespace_id: Optional namespace override. Falls back to the configured default.
413499
414500
Returns:
415501
JSON string confirming deletion or error message
416502
"""
417-
logger.info(f"Deleting entity: {entity_id}")
503+
resolved_ns = _resolve_namespace(namespace_id)
504+
logger.info(f"Deleting entity: {entity_id} from namespace: {resolved_ns}")
418505

419506
try:
420-
# Use EvolveClient.delete_entity_by_id() to delete the entity
421-
get_client().delete_entity_by_id(namespace_id=evolve_config.namespace_id, entity_id=entity_id)
507+
get_client().delete_entity_by_id(namespace_id=resolved_ns, entity_id=entity_id)
422508
return json.dumps({"success": True, "message": f"Entity {entity_id} deleted successfully"})
423509
except EvolveException as e:
424510
logger.exception(f"Error deleting entity {entity_id}: {str(e)}")

0 commit comments

Comments
 (0)