@@ -53,9 +53,10 @@ async def store_file(content: bytes, filename: str) -> str:
5353# Context variables for artifact/workspace management
5454_artifact_store : ContextVar [ArtifactStore | None ] = ContextVar ("artifact_store" , default = None )
5555
56- # Global singleton store (fallback when context not available)
56+ # Global singleton store (fallback when context not available).
57+ # RLock lets the same thread re-enter (e.g. during testing teardown).
5758_global_artifact_store : ArtifactStore | None = None
58- _global_store_lock = threading .Lock ()
59+ _global_store_lock = threading .RLock ()
5960
6061
6162def set_artifact_store (store : ArtifactStore ) -> None :
@@ -176,31 +177,43 @@ async def create_blob_namespace(
176177 """
177178 Convenience function to create a blob namespace.
178179
180+ ``session_id`` and ``user_id`` are read from the active request context
181+ when not supplied explicitly — so callers inside an MCP tool handler get
182+ the right scoping automatically without having to thread identifiers through
183+ every call site. Explicit arguments always take precedence over context.
184+
179185 Args:
180186 scope: Storage scope (defaults to SESSION)
181- session_id: Session ID (auto-allocated if not provided)
182- user_id: User ID (required for USER scope)
183- **kwargs: Additional parameters for create_namespace
187+ session_id: Session ID override; falls back to ``get_session_id()`` from context
188+ user_id: User ID override; falls back to ``get_user_id()`` from context
189+ **kwargs: Additional parameters forwarded to `` create_namespace``
184190
185191 Returns:
186- NamespaceInfo for created blob namespace
192+ NamespaceInfo for the created blob namespace
187193
188194 Examples:
189- >>> # Session-scoped blob
195+ >>> # Session-scoped — session_id injected from request context
190196 >>> ns = await create_blob_namespace()
191197
192- >>> # User-scoped blob
193- >>> ns = await create_blob_namespace(
194- ... scope=StorageScope.USER,
195- ... user_id="alice"
196- ... )
198+ >>> # User-scoped — user_id injected from OAuth context
199+ >>> ns = await create_blob_namespace(scope=StorageScope.USER)
200+
201+ >>> # Explicit override (e.g. admin code acting on behalf of a user)
202+ >>> ns = await create_blob_namespace(scope=StorageScope.USER, user_id="alice" )
197203 """
198204 from chuk_artifacts import NamespaceType
199205 from chuk_artifacts import StorageScope as Scope
206+ from chuk_mcp_server .context import get_session_id , get_user_id
200207
201208 if scope is None :
202209 scope = Scope .SESSION
203210
211+ # Fall back to request-context identifiers when not supplied explicitly
212+ if session_id is None :
213+ session_id = get_session_id ()
214+ if user_id is None :
215+ user_id = get_user_id ()
216+
204217 store = get_artifact_store ()
205218 ns : NamespaceInfo = await store .create_namespace (
206219 type = NamespaceType .BLOB ,
@@ -223,35 +236,49 @@ async def create_workspace_namespace(
223236 """
224237 Convenience function to create a workspace namespace.
225238
239+ ``session_id`` and ``user_id`` are read from the active request context
240+ when not supplied explicitly — so callers inside an MCP tool handler get
241+ the right scoping automatically. Explicit arguments always take precedence.
242+
226243 Args:
227244 name: Workspace name
228245 scope: Storage scope (defaults to SESSION)
229- session_id: Session ID (auto-allocated if not provided)
230- user_id: User ID (required for USER scope)
246+ session_id: Session ID override; falls back to ``get_session_id()`` from context
247+ user_id: User ID override; falls back to ``get_user_id()`` from context
231248 provider_type: VFS provider (vfs-memory, vfs-filesystem, vfs-s3, vfs-sqlite)
232- **kwargs: Additional parameters for create_namespace
249+ **kwargs: Additional parameters forwarded to `` create_namespace``
233250
234251 Returns:
235- NamespaceInfo for created workspace namespace
252+ NamespaceInfo for the created workspace namespace
236253
237254 Examples:
238- >>> # Session-scoped workspace
255+ >>> # Session-scoped — session_id injected from request context
239256 >>> ws = await create_workspace_namespace("my-project")
240257
241- >>> # User-scoped workspace with filesystem backing
258+ >>> # User-scoped — user_id injected from OAuth context
259+ >>> ws = await create_workspace_namespace("my-project", scope=StorageScope.USER)
260+
261+ >>> # Explicit override
242262 >>> ws = await create_workspace_namespace(
243263 ... name="alice-project",
244264 ... scope=StorageScope.USER,
245265 ... user_id="alice",
246- ... provider_type="vfs-filesystem"
266+ ... provider_type="vfs-filesystem",
247267 ... )
248268 """
249269 from chuk_artifacts import NamespaceType
250270 from chuk_artifacts import StorageScope as Scope
271+ from chuk_mcp_server .context import get_session_id , get_user_id
251272
252273 if scope is None :
253274 scope = Scope .SESSION
254275
276+ # Fall back to request-context identifiers when not supplied explicitly
277+ if session_id is None :
278+ session_id = get_session_id ()
279+ if user_id is None :
280+ user_id = get_user_id ()
281+
255282 store = get_artifact_store ()
256283 ns : NamespaceInfo = await store .create_namespace (
257284 type = NamespaceType .WORKSPACE ,
@@ -334,6 +361,85 @@ async def read_workspace_file(namespace_id: str, path: str) -> bytes:
334361 return cast (bytes , await store .read_namespace (namespace_id , path = path ))
335362
336363
364+ def list_my_namespaces (
365+ scope : StorageScope | None = None ,
366+ session_id : str | None = None ,
367+ user_id : str | None = None ,
368+ ) -> list [NamespaceInfo ]:
369+ """
370+ List namespaces belonging to the current user or session.
371+
372+ Unlike calling ``get_artifact_store().list_namespaces()`` directly, this
373+ helper automatically pulls ``session_id`` and ``user_id`` from the active
374+ request context and **refuses to list without at least one scoping
375+ identifier** (unless ``scope=StorageScope.SANDBOX`` is given). This
376+ prevents the common mistake of accidentally returning every namespace in
377+ the store when user/session context is absent.
378+
379+ Args:
380+ scope: Optional scope filter. When ``StorageScope.SANDBOX`` is passed
381+ the listing is unfiltered by design (sandbox is shared).
382+ session_id: Session ID override; falls back to ``get_session_id()``
383+ user_id: User ID override; falls back to ``get_user_id()``
384+
385+ Returns:
386+ List of NamespaceInfo matching the current user / session scope.
387+
388+ Raises:
389+ RuntimeError: When both ``session_id`` and ``user_id`` are ``None``
390+ and the scope is not SANDBOX. Call
391+ ``get_artifact_store().list_namespaces()`` directly if you
392+ intentionally need an unscoped listing.
393+
394+ Examples:
395+ >>> # List namespaces for the current session (session_id from context)
396+ >>> my_ns = list_my_namespaces()
397+
398+ >>> # List only user-scoped namespaces (user_id from OAuth context)
399+ >>> from chuk_artifacts import StorageScope
400+ >>> my_ns = list_my_namespaces(scope=StorageScope.USER)
401+
402+ >>> # Explicit override — admin acting on behalf of a specific user
403+ >>> my_ns = list_my_namespaces(user_id="alice")
404+ """
405+ from chuk_artifacts import StorageScope as Scope
406+ from chuk_mcp_server .context import get_session_id , get_user_id
407+
408+ # Fall back to request-context identifiers when not supplied explicitly
409+ if session_id is None :
410+ session_id = get_session_id ()
411+ if user_id is None :
412+ user_id = get_user_id ()
413+
414+ store = get_artifact_store ()
415+
416+ # SANDBOX scope is shared by design — listing without user/session is fine
417+ try :
418+ if scope is not None and scope == Scope .SANDBOX :
419+ return store .list_namespaces (scope = scope )
420+ except Exception :
421+ pass # scope comparison may fail if Scope is unavailable; fall through
422+
423+ # Every other scope requires at least one identifier to prevent full-bucket exposure
424+ if session_id is None and user_id is None :
425+ raise RuntimeError (
426+ "list_my_namespaces() requires either a session or user context. "
427+ "This prevents accidentally listing all namespaces across all users. "
428+ "Use get_artifact_store().list_namespaces() directly if you intentionally "
429+ "need an unscoped listing (e.g. for SANDBOX-scope items)."
430+ )
431+
432+ kwargs : dict [str , Any ] = {}
433+ if scope is not None :
434+ kwargs ["scope" ] = scope
435+ if user_id is not None :
436+ kwargs ["user_id" ] = user_id
437+ if session_id is not None :
438+ kwargs ["session_id" ] = session_id
439+
440+ return store .list_namespaces (** kwargs )
441+
442+
337443def get_namespace_vfs (namespace_id : str ) -> AsyncVirtualFileSystem :
338444 """
339445 Get VFS instance for namespace.
@@ -360,9 +466,12 @@ def get_namespace_vfs(namespace_id: str) -> AsyncVirtualFileSystem:
360466 "get_artifact_store" ,
361467 "set_global_artifact_store" ,
362468 "has_artifact_store" ,
363- # Convenience functions
469+ # Namespace creation (context-aware)
364470 "create_blob_namespace" ,
365471 "create_workspace_namespace" ,
472+ # Namespace listing (context-aware, isolation-safe)
473+ "list_my_namespaces" ,
474+ # Data I/O
366475 "write_blob" ,
367476 "read_blob" ,
368477 "write_workspace_file" ,
0 commit comments