Skip to content

Commit ea4c8a0

Browse files
committed
improved isolation
1 parent 93ba986 commit ea4c8a0

5 files changed

Lines changed: 1342 additions & 974 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "chuk-mcp-server"
7-
version = "0.25.3"
7+
version = "0.25.4"
88
description = "A developer-friendly MCP framework powered by chuk_mcp"
99
readme = "README.md"
1010
license = {text = "Apache-2.0"}

src/chuk_mcp_server/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def hello_azure(name: str) -> str:
106106
get_artifact_store,
107107
get_namespace_vfs,
108108
has_artifact_store,
109+
list_my_namespaces,
109110
read_blob,
110111
read_workspace_file,
111112
set_artifact_store,
@@ -139,6 +140,7 @@ def has_artifact_store() -> bool:
139140

140141
create_blob_namespace = _artifact_not_available
141142
create_workspace_namespace = _artifact_not_available
143+
list_my_namespaces = _artifact_not_available
142144
write_blob = _artifact_not_available
143145
read_blob = _artifact_not_available
144146
write_workspace_file = _artifact_not_available
@@ -330,8 +332,9 @@ def run(transport: str = "http", **kwargs: Any) -> None:
330332
"set_artifact_store", # Set artifact store in context
331333
"set_global_artifact_store", # Set global artifact store
332334
"has_artifact_store", # Check if artifact store available
333-
"create_blob_namespace", # Create blob namespace
334-
"create_workspace_namespace", # Create workspace namespace
335+
"create_blob_namespace", # Create blob namespace (context-aware)
336+
"create_workspace_namespace", # Create workspace namespace (context-aware)
337+
"list_my_namespaces", # List namespaces scoped to current user/session
335338
"write_blob", # Write to blob namespace
336339
"read_blob", # Read from blob namespace
337340
"write_workspace_file", # Write file to workspace

src/chuk_mcp_server/artifacts_context.py

Lines changed: 129 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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

6162
def 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+
337443
def 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

Comments
 (0)