From 578a60b2bc6041d9bfc0ce8c7515f68ec73ac9fa Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 01:30:47 -0300 Subject: [PATCH 1/8] Prefer x-conversation-id over a shared MCP session when the client sends it. --- config/storage.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/config/storage.py b/config/storage.py index b3e387d..576c7e8 100644 --- a/config/storage.py +++ b/config/storage.py @@ -226,6 +226,7 @@ class DefaultSessionScopeResolver(SessionScopeResolverPort): Resolve scope from request/ctx metadata. Hosted HTTP receives `Mcp-Session-Id` via header. + If the client also sends `x-conversation-id`, that value is the partition key. Local stdio/docker falls back to FastMCP context session_id when available. """ @@ -235,9 +236,14 @@ def _resolve_session_id(ctx: Optional[Context]) -> str: return "default" request = getattr(getattr(ctx, "request_context", None), "request", None) if request is not None: - session_id = request.headers.get("mcp-session-id") - if session_id and session_id.strip(): - return session_id.strip() + headers = getattr(request, "headers", None) + if headers is not None: + conversation_id = headers.get("x-conversation-id") + if conversation_id and str(conversation_id).strip(): + return str(conversation_id).strip() + session_id = headers.get("mcp-session-id") + if session_id and session_id.strip(): + return session_id.strip() session_id = getattr(ctx, "session_id", None) if session_id is not None and str(session_id).strip(): return str(session_id).strip() From 9ddf256970c3f3786a5fdd1f230a44fd661b11b5 Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 01:44:18 -0300 Subject: [PATCH 2/8] Add session_scope_id to tool results. --- models/result.py | 7 +++++++ tools/mcp_entrypoint.py | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/models/result.py b/models/result.py index 9d8e716..549fcb6 100644 --- a/models/result.py +++ b/models/result.py @@ -29,6 +29,7 @@ class BaseResult(BaseModel): tool_call_finished_at: Optional[str] = Field(description="ISO timestamp when tool action finished", default=None) tool_call_duration_ms: Optional[int] = Field(description="Tool action duration in milliseconds", default=None) debug: Optional[dict[str, Any]] = Field(description="Optional debug metrics for tool calls", default=None) + session_scope_id: Optional[str] = Field(description="Partition key used for tasks and dataframes in this tool call.",default=None,) def append_warnings(self, messages: List[str]): if not self.warning: @@ -121,3 +122,9 @@ def debug(self) -> Optional[dict[str, Any]]: if not isinstance(self.structuredContent, dict): return None return self.structuredContent.get("debug") + + @property + def session_scope_id(self) -> Optional[str]: + if not isinstance(self.structuredContent, dict): + return None + return self.structuredContent.get("session_scope_id") diff --git a/tools/mcp_entrypoint.py b/tools/mcp_entrypoint.py index 58e1894..db473fc 100644 --- a/tools/mcp_entrypoint.py +++ b/tools/mcp_entrypoint.py @@ -76,7 +76,7 @@ async def _run() -> BaseResult: return await dispatch(action, args, token, ctx) try: - return await run_tool_with_runtime( + result = await run_tool_with_runtime( runtime, name, action, @@ -87,6 +87,9 @@ async def _run() -> BaseResult: dataframe_excluded_actions=excluded_actions, disable_dataframe_materialization=disable_materialization, ) + if isinstance(result, BaseResult) and not result.error: + result.session_scope_id = runtime.scope_resolver.resolve(ctx, token).mcp_session_id + return result except httpx.HTTPStatusError: return BaseResult(error=f"Error: {format_sanitized_traceback()}") except Exception: From 674f18fcfeb5a1321d0c9763ed3c6c7d6b3bc744 Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 01:51:27 -0300 Subject: [PATCH 3/8] args.session_scope_id as the partition key. --- config/storage.py | 15 ++++++++++++--- tools/mcp_entrypoint.py | 7 +++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/config/storage.py b/config/storage.py index 576c7e8..99d5382 100644 --- a/config/storage.py +++ b/config/storage.py @@ -17,6 +17,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +import contextvars import os from pathlib import Path from typing import Any, List, Literal, Optional, Protocol, runtime_checkable @@ -221,17 +222,25 @@ def resolve(self, ctx: Optional[Context], token: Optional[BzmToken]) -> SessionS raise NotImplementedError +# Set for one tool call when args.session_scope_id is present (see mcp_entrypoint). +_tool_session_scope_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "tool_session_scope_id", default=None +) + + class DefaultSessionScopeResolver(SessionScopeResolverPort): """ Resolve scope from request/ctx metadata. - Hosted HTTP receives `Mcp-Session-Id` via header. - If the client also sends `x-conversation-id`, that value is the partition key. - Local stdio/docker falls back to FastMCP context session_id when available. + Prefer args.session_scope_id (same chat), then `x-conversation-id`, + then `Mcp-Session-Id` / FastMCP ``ctx.session_id``. """ @staticmethod def _resolve_session_id(ctx: Optional[Context]) -> str: + explicit = _tool_session_scope_id.get() + if explicit: + return explicit if ctx is None: return "default" request = getattr(getattr(ctx, "request_context", None), "request", None) diff --git a/tools/mcp_entrypoint.py b/tools/mcp_entrypoint.py index db473fc..5738662 100644 --- a/tools/mcp_entrypoint.py +++ b/tools/mcp_entrypoint.py @@ -22,6 +22,7 @@ from config.blazemeter import SUPPORT_MESSAGE from config.runtime import AppRuntime +from config.storage import _tool_session_scope_id from config.token import BzmToken from models.result import BaseResult from tools.runtime_tools import run_tool_with_runtime @@ -69,6 +70,10 @@ async def _tool( action, args = normalize_action_args(arguments) if not action: return BaseResult(error="Missing required argument 'action' within tool arguments.") + explicit = args.pop("session_scope_id", None) + scope_token = _tool_session_scope_id.set( + explicit.strip() if isinstance(explicit, str) and explicit.strip() else None + ) runtime.configure_context(ctx) token = runtime.auth.get_token(ctx) @@ -97,5 +102,7 @@ async def _run() -> BaseResult: if support_message: return BaseResult(error=f"Error: {detail}\n{support_message}") return BaseResult(error=f"Error: {detail}") + finally: + _tool_session_scope_id.reset(scope_token) return _tool From aae5f48e7d6612293f72c20b00eadc3ba9d38c04 Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 02:05:57 -0300 Subject: [PATCH 4/8] Mint a session_scope_id when no chat key is provided instead of using Mcp-Session-Id. --- config/storage.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/config/storage.py b/config/storage.py index 99d5382..4348bde 100644 --- a/config/storage.py +++ b/config/storage.py @@ -19,6 +19,7 @@ from dataclasses import dataclass import contextvars import os +import secrets from pathlib import Path from typing import Any, List, Literal, Optional, Protocol, runtime_checkable from urllib.parse import quote @@ -222,7 +223,7 @@ def resolve(self, ctx: Optional[Context], token: Optional[BzmToken]) -> SessionS raise NotImplementedError -# Set for one tool call when args.session_scope_id is present (see mcp_entrypoint). +# Per tool call: args.session_scope_id, or a minted id when no chat key is provided. _tool_session_scope_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( "tool_session_scope_id", default=None ) @@ -232,8 +233,8 @@ class DefaultSessionScopeResolver(SessionScopeResolverPort): """ Resolve scope from request/ctx metadata. - Prefer args.session_scope_id (same chat), then `x-conversation-id`, - then `Mcp-Session-Id` / FastMCP ``ctx.session_id``. + Prefer args.session_scope_id (same chat), then `x-conversation-id`. + Otherwise mint an id so a shared MCP session cannot leak across chats. """ @staticmethod @@ -244,19 +245,14 @@ def _resolve_session_id(ctx: Optional[Context]) -> str: if ctx is None: return "default" request = getattr(getattr(ctx, "request_context", None), "request", None) - if request is not None: - headers = getattr(request, "headers", None) - if headers is not None: - conversation_id = headers.get("x-conversation-id") - if conversation_id and str(conversation_id).strip(): - return str(conversation_id).strip() - session_id = headers.get("mcp-session-id") - if session_id and session_id.strip(): - return session_id.strip() - session_id = getattr(ctx, "session_id", None) - if session_id is not None and str(session_id).strip(): - return str(session_id).strip() - return "default" + headers = getattr(request, "headers", None) if request is not None else None + if headers is not None: + conversation_id = headers.get("x-conversation-id") + if conversation_id and str(conversation_id).strip(): + return str(conversation_id).strip() + minted = secrets.token_hex(8) + _tool_session_scope_id.set(minted) + return minted @staticmethod def _resolve_user_id(token: Optional[BzmToken]) -> str: From 6f896da653993940268becfe7dedce47e3cbdc34 Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 02:13:16 -0300 Subject: [PATCH 5/8] instruct agents to reuse session_scope_id. --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index d6772ca..04410b0 100644 --- a/main.py +++ b/main.py @@ -517,6 +517,7 @@ def build_mcp_server( - **Always confirm context**: Always identify and confirm workspace/project before operations. - **Proactive Troubleshooting**: Use the skills for troubleshooting any detected issues. - **Failure criteria**: The same field names appear when you read a test and when you configure failure criteria (`failure_criteria` on the test); the server handles BlazeMeter’s REST format internally. Use `failure_criteria_meta` for field definitions and KPI/condition catalogs. When describing criteria to the user, use `meta.general_labels`, `meta.rule_field_labels`, `meta.kpi_labels`, and `meta.condition_labels`; use raw metric and operator ids only inside tool calls. Use `configure_failure_criteria` only after user confirmation; it replaces all rules unless you merge from a prior read. +- **Session isolation**: Successful tool results include `session_scope_id`. Pass that same value as `args.session_scope_id` on later tool calls in this chat so tasks and dataframes stay in the same store. Do not reuse a `session_scope_id` from another chat. """ mcp = FastMCP( "blazemeter-mcp", From fecefc4873ec346918c98d2f6022daec16bf192e Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 02:21:37 -0300 Subject: [PATCH 6/8] docs: describe chat-scoped storage partition key --- docs/hosted-http.md | 5 ++++- docs/hosted-mvp-runbook.md | 11 +++++++++-- tools/tools_manager.py | 9 ++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/hosted-http.md b/docs/hosted-http.md index 1cc4f17..2a1ebac 100644 --- a/docs/hosted-http.md +++ b/docs/hosted-http.md @@ -75,5 +75,8 @@ On streamable-http, session partitions are stored via `HttpSessionStorageProvide ## Hosted MVP limitations -- Session dataframes/tasks live in the Storage Service keyed by `{user_id}/{mcp_session_id}`. +- Session dataframes/tasks live in the Storage Service at + `/session-partitions/{user_id}/{mcp_session_id}`. The second segment is + `args.session_scope_id`, `x-conversation-id`, or a minted id — not the + HTTP `Mcp-Session-Id` header. - `upload_assets` and other local file lookup/upload paths are rejected. Use a local stdio or Docker MCP installation for those workflows, or wait for remote file access. diff --git a/docs/hosted-mvp-runbook.md b/docs/hosted-mvp-runbook.md index d317cfb..008d761 100644 --- a/docs/hosted-mvp-runbook.md +++ b/docs/hosted-mvp-runbook.md @@ -31,8 +31,15 @@ async tasks share the same session partitions as dataframes. Tool registrations call `run_tool_with_runtime(runtime, ...)` so tracing stays unaware of dataframe types. There is no process-global dataframe store. -Partition key: `{user_id}/{mcp_session_id}` via `DefaultSessionScopeResolver` -(`Mcp-Session-Id` header, then FastMCP `ctx.session_id`). +Storage path: `{user_id}/{mcp_session_id}` via `DefaultSessionScopeResolver`. +The second segment is a **chat** key, not the HTTP `Mcp-Session-Id` header: + +1. `args.session_scope_id` (reuse the id from a previous successful tool result) +2. `x-conversation-id` header, if the client sends one +3. otherwise a minted id for this tool call + +`Mcp-Session-Id` / FastMCP `ctx.session_id` are transport session ids only. They +are not used as the dataframe/task partition key. ## Session Storage Service diff --git a/tools/tools_manager.py b/tools/tools_manager.py index 765d233..04a8127 100644 --- a/tools/tools_manager.py +++ b/tools/tools_manager.py @@ -714,14 +714,16 @@ async def _dispatch(action, args, token, ctx): - tasks_status: Lightweight task status by task ID (no task_result payload). args(dict): task_id (str, required); wait_for_terminal_ms (int, optional); poll_interval_ms (int, optional) -- tasks_list: List tasks for the current MCP session. - args(dict): status (str, optional); status_list (list[str], optional) +- tasks_list: List tasks for the current chat-scoped session. + args(dict): status (str, optional); status_list (list[str], optional); + session_scope_id (str, optional) - tasks_cancel: Cancel a running/queued task on this worker when a local handle exists. Hosted note: cancel is process-local; other workers may only record cancel in Storage. args(dict): task_id (str, required) - tasks_remove: Remove a task from the session registry. args(dict): task_id (str, required) -- dataframes_list: List dataframes and metadata for the current MCP session. +- dataframes_list: List dataframes and metadata for the current chat-scoped session. + args(dict): session_scope_id (str, optional) - dataframes_get: Get dataframe metadata and schema by dataframe ID. args(dict): dataframe_id (str, required) - dataframes_schema_groups: Group dataframe schemas for multi-dataframe queries. @@ -739,6 +741,7 @@ async def _dispatch(action, args, token, ctx): - **CRITICAL**: Before writing any dataframe SQL query, call `dataframes_sql_help` first. - ORDER BY + LIMIT + OFFSET are mandatory in every dataframe query. - After a long-running tool returns a task snapshot, poll with tasks_status then tasks_get. +- **CRITICAL**: Reuse `session_scope_id` from this chat's previous successful tool result in `args.session_scope_id`. Never copy a `session_scope_id` from another chat. """, dispatch=_dispatch, excluded_actions=set(TOOLS_ACTIONS_SKIP_AUTO_DATAFRAME), From ef1135bb609b92a2a281502508c56c5b237212a5 Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 03:03:44 -0300 Subject: [PATCH 7/8] Remove x-conversation-id from session partition resolution. --- config/storage.py | 11 ++--------- docs/hosted-http.md | 3 +-- docs/hosted-mvp-runbook.md | 3 +-- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/config/storage.py b/config/storage.py index 4348bde..209da0f 100644 --- a/config/storage.py +++ b/config/storage.py @@ -223,7 +223,7 @@ def resolve(self, ctx: Optional[Context], token: Optional[BzmToken]) -> SessionS raise NotImplementedError -# Per tool call: args.session_scope_id, or a minted id when no chat key is provided. +# Per tool call: args.session_scope_id, or a minted id when none is provided. _tool_session_scope_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( "tool_session_scope_id", default=None ) @@ -231,9 +231,8 @@ def resolve(self, ctx: Optional[Context], token: Optional[BzmToken]) -> SessionS class DefaultSessionScopeResolver(SessionScopeResolverPort): """ - Resolve scope from request/ctx metadata. + Resolve scope from args.session_scope_id (same chat). - Prefer args.session_scope_id (same chat), then `x-conversation-id`. Otherwise mint an id so a shared MCP session cannot leak across chats. """ @@ -244,12 +243,6 @@ def _resolve_session_id(ctx: Optional[Context]) -> str: return explicit if ctx is None: return "default" - request = getattr(getattr(ctx, "request_context", None), "request", None) - headers = getattr(request, "headers", None) if request is not None else None - if headers is not None: - conversation_id = headers.get("x-conversation-id") - if conversation_id and str(conversation_id).strip(): - return str(conversation_id).strip() minted = secrets.token_hex(8) _tool_session_scope_id.set(minted) return minted diff --git a/docs/hosted-http.md b/docs/hosted-http.md index 2a1ebac..9d520eb 100644 --- a/docs/hosted-http.md +++ b/docs/hosted-http.md @@ -77,6 +77,5 @@ On streamable-http, session partitions are stored via `HttpSessionStorageProvide - Session dataframes/tasks live in the Storage Service at `/session-partitions/{user_id}/{mcp_session_id}`. The second segment is - `args.session_scope_id`, `x-conversation-id`, or a minted id — not the - HTTP `Mcp-Session-Id` header. + `args.session_scope_id` or a minted id — not the HTTP `Mcp-Session-Id` header. - `upload_assets` and other local file lookup/upload paths are rejected. Use a local stdio or Docker MCP installation for those workflows, or wait for remote file access. diff --git a/docs/hosted-mvp-runbook.md b/docs/hosted-mvp-runbook.md index 008d761..834513e 100644 --- a/docs/hosted-mvp-runbook.md +++ b/docs/hosted-mvp-runbook.md @@ -35,8 +35,7 @@ Storage path: `{user_id}/{mcp_session_id}` via `DefaultSessionScopeResolver`. The second segment is a **chat** key, not the HTTP `Mcp-Session-Id` header: 1. `args.session_scope_id` (reuse the id from a previous successful tool result) -2. `x-conversation-id` header, if the client sends one -3. otherwise a minted id for this tool call +2. otherwise a minted id for this tool call `Mcp-Session-Id` / FastMCP `ctx.session_id` are transport session ids only. They are not used as the dataframe/task partition key. From 369f7bb595c5e836115dddc81887c5c3d877913c Mon Sep 17 00:00:00 2001 From: alejandroaires Date: Tue, 8 Sep 2026 03:18:14 -0300 Subject: [PATCH 8/8] Fix TestResolveSessionScope --- tests/conftest.py | 13 +++++++++++++ tests/test_tools_manager_dataframes.py | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a4f3d2e..4b5b52e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,10 @@ def run_async(coro): def make_ctx(token: BzmToken, session_id: str): + from config.storage import _tool_session_scope_id + + # Stand-in for args.session_scope_id so manager tests share a stable partition. + _tool_session_scope_id.set(session_id) request_state = SimpleNamespace( **{ BZM_TOKEN_STATE_ATTR: token, @@ -44,6 +48,15 @@ def make_ctx(token: BzmToken, session_id: str): ) +@pytest.fixture(autouse=True) +def reset_tool_session_scope_id(): + from config.storage import _tool_session_scope_id + + token = _tool_session_scope_id.set(None) + yield + _tool_session_scope_id.reset(token) + + @pytest.fixture(autouse=True) def reset_dataframe_session_locks(): from tools import dataframe_manager as dataframe_manager_module diff --git a/tests/test_tools_manager_dataframes.py b/tests/test_tools_manager_dataframes.py index 38ecdd7..c319ece 100644 --- a/tests/test_tools_manager_dataframes.py +++ b/tests/test_tools_manager_dataframes.py @@ -23,10 +23,14 @@ class TestResolveSessionScope: - def test_uses_token_id_and_ctx_session(self): + def test_uses_token_id_and_mints_when_no_chat_key(self): token = BzmToken("api-key-id", "secret") ctx = SimpleNamespace(session_id="mcp-abc") - assert resolve_session_scope(ctx, token) == SessionScope("api-key-id", "mcp-abc") + first = resolve_session_scope(ctx, token) + second = resolve_session_scope(ctx, token) + assert first.user_id == "api-key-id" + assert first.mcp_session_id != "mcp-abc" + assert first == second def test_defaults_when_missing(self): assert resolve_session_scope(None, None) == SessionScope("anonymous", "default")